Merge branch 'HKUDS:main' into main
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +2 -1
- README.md +17 -0
- env.example +7 -5
- lightrag/__init__.py +1 -1
- lightrag/api/auth.py +66 -7
- lightrag/api/gunicorn_config.py +3 -1
- lightrag/api/lightrag_server.py +44 -6
- lightrag/api/routers/document_routes.py +31 -11
- lightrag/api/run_with_gunicorn.py +2 -2
- lightrag/api/utils_api.py +44 -12
- lightrag/api/webui/assets/{index-DwcJE583.js → index-4I5HV9Fr.js} +0 -0
- lightrag/api/webui/assets/index-BSOt8Nur.css +0 -0
- lightrag/api/webui/assets/index-BV5s8k-a.css +0 -0
- lightrag/api/webui/index.html +0 -0
- lightrag/base.py +2 -0
- lightrag/kg/json_doc_status_impl.py +3 -0
- lightrag/kg/networkx_impl.py +3 -0
- lightrag/kg/postgres_impl.py +49 -14
- lightrag/kg/shared_storage.py +93 -2
- lightrag/lightrag.py +125 -64
- lightrag/llm/hf.py +16 -1
- lightrag/operate.py +75 -18
- lightrag/prompt.py +6 -4
- lightrag/utils.py +33 -25
- lightrag_webui/bun.lock +16 -0
- lightrag_webui/env.development.smaple +2 -0
- lightrag_webui/env.local.sample +3 -0
- lightrag_webui/index.html +1 -1
- lightrag_webui/package.json +2 -0
- lightrag_webui/src/App.tsx +6 -10
- lightrag_webui/src/AppRouter.tsx +190 -0
- lightrag_webui/src/api/lightrag.ts +104 -1
- lightrag_webui/src/components/AppSettings.tsx +7 -2
- lightrag_webui/src/components/LanguageToggle.tsx +49 -0
- lightrag_webui/src/components/graph/FocusOnNode.tsx +24 -10
- lightrag_webui/src/components/graph/GraphControl.tsx +63 -25
- lightrag_webui/src/components/graph/GraphLabels.tsx +73 -43
- lightrag_webui/src/components/graph/GraphSearch.tsx +51 -33
- lightrag_webui/src/components/graph/LayoutsControl.tsx +174 -21
- lightrag_webui/src/components/graph/PropertiesView.tsx +94 -19
- lightrag_webui/src/components/graph/Settings.tsx +28 -56
- lightrag_webui/src/components/graph/SettingsDisplay.tsx +1 -1
- lightrag_webui/src/components/graph/ZoomControl.tsx +79 -9
- lightrag_webui/src/components/ui/Popover.tsx +10 -12
- lightrag_webui/src/components/ui/Tooltip.tsx +1 -1
- lightrag_webui/src/contexts/TabVisibilityProvider.tsx +10 -4
- lightrag_webui/src/features/DocumentManager.tsx +9 -13
- lightrag_webui/src/features/GraphViewer.tsx +64 -76
- lightrag_webui/src/features/LoginPage.tsx +177 -0
- lightrag_webui/src/features/RetrievalTesting.tsx +1 -1
.gitattributes
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
-
lightrag/api/webui/**
|
|
|
2 |
*.png filter=lfs diff=lfs merge=lfs -text
|
3 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
4 |
*.ico filter=lfs diff=lfs merge=lfs -text
|
|
|
1 |
+
lightrag/api/webui/** binary
|
2 |
+
lightrag/api/webui/** linguist-generated
|
3 |
*.png filter=lfs diff=lfs merge=lfs -text
|
4 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
5 |
*.ico filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
@@ -45,6 +45,7 @@ This repository hosts the code of LightRAG. The structure of this code is based
|
|
45 |
🎉 News
|
46 |
</summary>
|
47 |
|
|
|
48 |
- [X] [2025.02.05]🎯📢Our team has released [VideoRAG](https://github.com/HKUDS/VideoRAG) understanding extremely long-context videos.
|
49 |
- [X] [2025.01.13]🎯📢Our team has released [MiniRAG](https://github.com/HKUDS/MiniRAG) making RAG simpler with small models.
|
50 |
- [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'))
|
|
673 |
|
674 |
</details>
|
675 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
676 |
## Storage
|
677 |
|
678 |
<details>
|
|
|
45 |
🎉 News
|
46 |
</summary>
|
47 |
|
48 |
+
- [X] [2025.03.18]🎯📢LightRAG now supports citation functionality.
|
49 |
- [X] [2025.02.05]🎯📢Our team has released [VideoRAG](https://github.com/HKUDS/VideoRAG) understanding extremely long-context videos.
|
50 |
- [X] [2025.01.13]🎯📢Our team has released [MiniRAG](https://github.com/HKUDS/MiniRAG) making RAG simpler with small models.
|
51 |
- [X] [2025.01.06]🎯📢You can now [use PostgreSQL for Storage](#using-postgresql-for-storage).
|
|
|
674 |
|
675 |
</details>
|
676 |
|
677 |
+
<details>
|
678 |
+
<summary><b>Citation Functionality</b></summary>
|
679 |
+
|
680 |
+
By providing file paths, the system ensures that sources can be traced back to their original documents.
|
681 |
+
|
682 |
+
```python
|
683 |
+
# Define documents and their file paths
|
684 |
+
documents = ["Document content 1", "Document content 2"]
|
685 |
+
file_paths = ["path/to/doc1.txt", "path/to/doc2.txt"]
|
686 |
+
|
687 |
+
# Insert documents with file paths
|
688 |
+
rag.insert(documents, file_paths=file_paths)
|
689 |
+
```
|
690 |
+
|
691 |
+
</details>
|
692 |
+
|
693 |
## Storage
|
694 |
|
695 |
<details>
|
env.example
CHANGED
@@ -73,6 +73,8 @@ LLM_BINDING_HOST=http://localhost:11434
|
|
73 |
### Embedding Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)
|
74 |
EMBEDDING_MODEL=bge-m3:latest
|
75 |
EMBEDDING_DIM=1024
|
|
|
|
|
76 |
# EMBEDDING_BINDING_API_KEY=your_api_key
|
77 |
### ollama example
|
78 |
EMBEDDING_BINDING=ollama
|
@@ -151,9 +153,9 @@ QDRANT_URL=http://localhost:16333
|
|
151 |
### Redis
|
152 |
REDIS_URI=redis://localhost:6379
|
153 |
|
154 |
-
|
155 |
-
AUTH_USERNAME=admin
|
156 |
-
AUTH_PASSWORD=admin123
|
157 |
-
TOKEN_SECRET=your-key
|
158 |
-
TOKEN_EXPIRE_HOURS=4
|
159 |
WHITELIST_PATHS=/login,/health # white list
|
|
|
73 |
### Embedding Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)
|
74 |
EMBEDDING_MODEL=bge-m3:latest
|
75 |
EMBEDDING_DIM=1024
|
76 |
+
EMBEDDING_BATCH_NUM=32
|
77 |
+
EMBEDDING_FUNC_MAX_ASYNC=16
|
78 |
# EMBEDDING_BINDING_API_KEY=your_api_key
|
79 |
### ollama example
|
80 |
EMBEDDING_BINDING=ollama
|
|
|
153 |
### Redis
|
154 |
REDIS_URI=redis://localhost:6379
|
155 |
|
156 |
+
### For JWTt Auth
|
157 |
+
AUTH_USERNAME=admin # login name
|
158 |
+
AUTH_PASSWORD=admin123 # password
|
159 |
+
TOKEN_SECRET=your-key-for-LightRAG-API-Server # JWT key
|
160 |
+
TOKEN_EXPIRE_HOURS=4 # expire duration
|
161 |
WHITELIST_PATHS=/login,/health # white list
|
lightrag/__init__.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
2 |
|
3 |
-
__version__ = "1.2.
|
4 |
__author__ = "Zirui Guo"
|
5 |
__url__ = "https://github.com/HKUDS/LightRAG"
|
|
|
1 |
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
2 |
|
3 |
+
__version__ = "1.2.7"
|
4 |
__author__ = "Zirui Guo"
|
5 |
__url__ = "https://github.com/HKUDS/LightRAG"
|
lightrag/api/auth.py
CHANGED
@@ -3,11 +3,16 @@ from datetime import datetime, timedelta
|
|
3 |
import jwt
|
4 |
from fastapi import HTTPException, status
|
5 |
from pydantic import BaseModel
|
|
|
|
|
|
|
6 |
|
7 |
|
8 |
class TokenPayload(BaseModel):
|
9 |
-
sub: str
|
10 |
-
exp: datetime
|
|
|
|
|
11 |
|
12 |
|
13 |
class AuthHandler:
|
@@ -15,13 +20,60 @@ class AuthHandler:
|
|
15 |
self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46")
|
16 |
self.algorithm = "HS256"
|
17 |
self.expire_hours = int(os.getenv("TOKEN_EXPIRE_HOURS", 4))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
|
19 |
-
def create_token(self, username: str) -> str:
|
20 |
-
expire = datetime.utcnow() + timedelta(hours=self.expire_hours)
|
21 |
-
payload = TokenPayload(sub=username, exp=expire)
|
22 |
return jwt.encode(payload.dict(), self.secret, algorithm=self.algorithm)
|
23 |
|
24 |
-
def validate_token(self, token: str) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
try:
|
26 |
payload = jwt.decode(token, self.secret, algorithms=[self.algorithm])
|
27 |
expire_timestamp = payload["exp"]
|
@@ -31,7 +83,14 @@ class AuthHandler:
|
|
31 |
raise HTTPException(
|
32 |
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
33 |
)
|
34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
except jwt.PyJWTError:
|
36 |
raise HTTPException(
|
37 |
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
|
|
3 |
import jwt
|
4 |
from fastapi import HTTPException, status
|
5 |
from pydantic import BaseModel
|
6 |
+
from dotenv import load_dotenv
|
7 |
+
|
8 |
+
load_dotenv()
|
9 |
|
10 |
|
11 |
class TokenPayload(BaseModel):
|
12 |
+
sub: str # Username
|
13 |
+
exp: datetime # Expiration time
|
14 |
+
role: str = "user" # User role, default is regular user
|
15 |
+
metadata: dict = {} # Additional metadata
|
16 |
|
17 |
|
18 |
class AuthHandler:
|
|
|
20 |
self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46")
|
21 |
self.algorithm = "HS256"
|
22 |
self.expire_hours = int(os.getenv("TOKEN_EXPIRE_HOURS", 4))
|
23 |
+
self.guest_expire_hours = int(
|
24 |
+
os.getenv("GUEST_TOKEN_EXPIRE_HOURS", 2)
|
25 |
+
) # Guest token default expiration time
|
26 |
+
|
27 |
+
def create_token(
|
28 |
+
self,
|
29 |
+
username: str,
|
30 |
+
role: str = "user",
|
31 |
+
custom_expire_hours: int = None,
|
32 |
+
metadata: dict = None,
|
33 |
+
) -> str:
|
34 |
+
"""
|
35 |
+
Create JWT token
|
36 |
+
|
37 |
+
Args:
|
38 |
+
username: Username
|
39 |
+
role: User role, default is "user", guest is "guest"
|
40 |
+
custom_expire_hours: Custom expiration time (hours), if None use default value
|
41 |
+
metadata: Additional metadata
|
42 |
+
|
43 |
+
Returns:
|
44 |
+
str: Encoded JWT token
|
45 |
+
"""
|
46 |
+
# Choose default expiration time based on role
|
47 |
+
if custom_expire_hours is None:
|
48 |
+
if role == "guest":
|
49 |
+
expire_hours = self.guest_expire_hours
|
50 |
+
else:
|
51 |
+
expire_hours = self.expire_hours
|
52 |
+
else:
|
53 |
+
expire_hours = custom_expire_hours
|
54 |
+
|
55 |
+
expire = datetime.utcnow() + timedelta(hours=expire_hours)
|
56 |
+
|
57 |
+
# Create payload
|
58 |
+
payload = TokenPayload(
|
59 |
+
sub=username, exp=expire, role=role, metadata=metadata or {}
|
60 |
+
)
|
61 |
|
|
|
|
|
|
|
62 |
return jwt.encode(payload.dict(), self.secret, algorithm=self.algorithm)
|
63 |
|
64 |
+
def validate_token(self, token: str) -> dict:
|
65 |
+
"""
|
66 |
+
Validate JWT token
|
67 |
+
|
68 |
+
Args:
|
69 |
+
token: JWT token
|
70 |
+
|
71 |
+
Returns:
|
72 |
+
dict: Dictionary containing user information
|
73 |
+
|
74 |
+
Raises:
|
75 |
+
HTTPException: If token is invalid or expired
|
76 |
+
"""
|
77 |
try:
|
78 |
payload = jwt.decode(token, self.secret, algorithms=[self.algorithm])
|
79 |
expire_timestamp = payload["exp"]
|
|
|
83 |
raise HTTPException(
|
84 |
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
85 |
)
|
86 |
+
|
87 |
+
# Return complete payload instead of just username
|
88 |
+
return {
|
89 |
+
"username": payload["sub"],
|
90 |
+
"role": payload.get("role", "user"),
|
91 |
+
"metadata": payload.get("metadata", {}),
|
92 |
+
"exp": expire_time,
|
93 |
+
}
|
94 |
except jwt.PyJWTError:
|
95 |
raise HTTPException(
|
96 |
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
lightrag/api/gunicorn_config.py
CHANGED
@@ -29,7 +29,9 @@ preload_app = True
|
|
29 |
worker_class = "uvicorn.workers.UvicornWorker"
|
30 |
|
31 |
# Other Gunicorn configurations
|
32 |
-
timeout = int(
|
|
|
|
|
33 |
keepalive = int(os.getenv("KEEPALIVE", 5)) # Default 5s
|
34 |
|
35 |
# Logging configuration
|
|
|
29 |
worker_class = "uvicorn.workers.UvicornWorker"
|
30 |
|
31 |
# Other Gunicorn configurations
|
32 |
+
timeout = int(
|
33 |
+
os.getenv("TIMEOUT", 150 * 2)
|
34 |
+
) # Default 150s *2 to match run_with_gunicorn.py
|
35 |
keepalive = int(os.getenv("KEEPALIVE", 5)) # Default 5s
|
36 |
|
37 |
# Logging configuration
|
lightrag/api/lightrag_server.py
CHANGED
@@ -10,6 +10,7 @@ import logging.config
|
|
10 |
import uvicorn
|
11 |
import pipmaster as pm
|
12 |
from fastapi.staticfiles import StaticFiles
|
|
|
13 |
from pathlib import Path
|
14 |
import configparser
|
15 |
from ascii_colors import ASCIIColors
|
@@ -48,7 +49,7 @@ from .auth import auth_handler
|
|
48 |
# Load environment variables
|
49 |
# Updated to use the .env that is inside the current folder
|
50 |
# This update allows the user to put a different.env file for each lightrag folder
|
51 |
-
load_dotenv(
|
52 |
|
53 |
# Initialize config parser
|
54 |
config = configparser.ConfigParser()
|
@@ -341,25 +342,62 @@ def create_app(args):
|
|
341 |
ollama_api = OllamaAPI(rag, top_k=args.top_k)
|
342 |
app.include_router(ollama_api.router, prefix="/api")
|
343 |
|
344 |
-
@app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
345 |
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
346 |
username = os.getenv("AUTH_USERNAME")
|
347 |
password = os.getenv("AUTH_PASSWORD")
|
348 |
|
349 |
if not (username and password):
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
354 |
|
355 |
if form_data.username != username or form_data.password != password:
|
356 |
raise HTTPException(
|
357 |
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect credentials"
|
358 |
)
|
359 |
|
|
|
|
|
|
|
|
|
360 |
return {
|
361 |
-
"access_token":
|
362 |
"token_type": "bearer",
|
|
|
363 |
}
|
364 |
|
365 |
@app.get("/health", dependencies=[Depends(optional_api_key)])
|
|
|
10 |
import uvicorn
|
11 |
import pipmaster as pm
|
12 |
from fastapi.staticfiles import StaticFiles
|
13 |
+
from fastapi.responses import RedirectResponse
|
14 |
from pathlib import Path
|
15 |
import configparser
|
16 |
from ascii_colors import ASCIIColors
|
|
|
49 |
# Load environment variables
|
50 |
# Updated to use the .env that is inside the current folder
|
51 |
# This update allows the user to put a different.env file for each lightrag folder
|
52 |
+
load_dotenv()
|
53 |
|
54 |
# Initialize config parser
|
55 |
config = configparser.ConfigParser()
|
|
|
342 |
ollama_api = OllamaAPI(rag, top_k=args.top_k)
|
343 |
app.include_router(ollama_api.router, prefix="/api")
|
344 |
|
345 |
+
@app.get("/")
|
346 |
+
async def redirect_to_webui():
|
347 |
+
"""Redirect root path to /webui"""
|
348 |
+
return RedirectResponse(url="/webui")
|
349 |
+
|
350 |
+
@app.get("/auth-status", dependencies=[Depends(optional_api_key)])
|
351 |
+
async def get_auth_status():
|
352 |
+
"""Get authentication status and guest token if auth is not configured"""
|
353 |
+
username = os.getenv("AUTH_USERNAME")
|
354 |
+
password = os.getenv("AUTH_PASSWORD")
|
355 |
+
|
356 |
+
if not (username and password):
|
357 |
+
# Authentication not configured, return guest token
|
358 |
+
guest_token = auth_handler.create_token(
|
359 |
+
username="guest", role="guest", metadata={"auth_mode": "disabled"}
|
360 |
+
)
|
361 |
+
return {
|
362 |
+
"auth_configured": False,
|
363 |
+
"access_token": guest_token,
|
364 |
+
"token_type": "bearer",
|
365 |
+
"auth_mode": "disabled",
|
366 |
+
"message": "Authentication is disabled. Using guest access.",
|
367 |
+
}
|
368 |
+
|
369 |
+
return {"auth_configured": True, "auth_mode": "enabled"}
|
370 |
+
|
371 |
+
@app.post("/login", dependencies=[Depends(optional_api_key)])
|
372 |
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
373 |
username = os.getenv("AUTH_USERNAME")
|
374 |
password = os.getenv("AUTH_PASSWORD")
|
375 |
|
376 |
if not (username and password):
|
377 |
+
# Authentication not configured, return guest token
|
378 |
+
guest_token = auth_handler.create_token(
|
379 |
+
username="guest", role="guest", metadata={"auth_mode": "disabled"}
|
380 |
)
|
381 |
+
return {
|
382 |
+
"access_token": guest_token,
|
383 |
+
"token_type": "bearer",
|
384 |
+
"auth_mode": "disabled",
|
385 |
+
"message": "Authentication is disabled. Using guest access.",
|
386 |
+
}
|
387 |
|
388 |
if form_data.username != username or form_data.password != password:
|
389 |
raise HTTPException(
|
390 |
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect credentials"
|
391 |
)
|
392 |
|
393 |
+
# Regular user login
|
394 |
+
user_token = auth_handler.create_token(
|
395 |
+
username=username, role="user", metadata={"auth_mode": "enabled"}
|
396 |
+
)
|
397 |
return {
|
398 |
+
"access_token": user_token,
|
399 |
"token_type": "bearer",
|
400 |
+
"auth_mode": "enabled",
|
401 |
}
|
402 |
|
403 |
@app.get("/health", dependencies=[Depends(optional_api_key)])
|
lightrag/api/routers/document_routes.py
CHANGED
@@ -405,7 +405,7 @@ async def pipeline_index_file(rag: LightRAG, file_path: Path):
|
|
405 |
|
406 |
|
407 |
async def pipeline_index_files(rag: LightRAG, file_paths: List[Path]):
|
408 |
-
"""Index multiple files
|
409 |
|
410 |
Args:
|
411 |
rag: LightRAG instance
|
@@ -416,12 +416,12 @@ async def pipeline_index_files(rag: LightRAG, file_paths: List[Path]):
|
|
416 |
try:
|
417 |
enqueued = False
|
418 |
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
enqueued = any(await asyncio.gather(*tasks))
|
424 |
|
|
|
425 |
if enqueued:
|
426 |
await rag.apipeline_process_enqueue_documents()
|
427 |
except Exception as e:
|
@@ -472,14 +472,34 @@ async def run_scanning_process(rag: LightRAG, doc_manager: DocumentManager):
|
|
472 |
total_files = len(new_files)
|
473 |
logger.info(f"Found {total_files} new files to index.")
|
474 |
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
480 |
|
481 |
except Exception as e:
|
482 |
logger.error(f"Error during scanning process: {str(e)}")
|
|
|
483 |
|
484 |
|
485 |
def create_document_routes(
|
|
|
405 |
|
406 |
|
407 |
async def pipeline_index_files(rag: LightRAG, file_paths: List[Path]):
|
408 |
+
"""Index multiple files sequentially to avoid high CPU load
|
409 |
|
410 |
Args:
|
411 |
rag: LightRAG instance
|
|
|
416 |
try:
|
417 |
enqueued = False
|
418 |
|
419 |
+
# Process files sequentially
|
420 |
+
for file_path in file_paths:
|
421 |
+
if await pipeline_enqueue_file(rag, file_path):
|
422 |
+
enqueued = True
|
|
|
423 |
|
424 |
+
# Process the queue only if at least one file was successfully enqueued
|
425 |
if enqueued:
|
426 |
await rag.apipeline_process_enqueue_documents()
|
427 |
except Exception as e:
|
|
|
472 |
total_files = len(new_files)
|
473 |
logger.info(f"Found {total_files} new files to index.")
|
474 |
|
475 |
+
if not new_files:
|
476 |
+
return
|
477 |
+
|
478 |
+
# Get MAX_PARALLEL_INSERT from global_args
|
479 |
+
max_parallel = global_args["max_parallel_insert"]
|
480 |
+
# Calculate batch size as 2 * MAX_PARALLEL_INSERT
|
481 |
+
batch_size = 2 * max_parallel
|
482 |
+
|
483 |
+
# Process files in batches
|
484 |
+
for i in range(0, total_files, batch_size):
|
485 |
+
batch_files = new_files[i : i + batch_size]
|
486 |
+
batch_num = i // batch_size + 1
|
487 |
+
total_batches = (total_files + batch_size - 1) // batch_size
|
488 |
+
|
489 |
+
logger.info(
|
490 |
+
f"Processing batch {batch_num}/{total_batches} with {len(batch_files)} files"
|
491 |
+
)
|
492 |
+
await pipeline_index_files(rag, batch_files)
|
493 |
+
|
494 |
+
# Log progress
|
495 |
+
processed = min(i + batch_size, total_files)
|
496 |
+
logger.info(
|
497 |
+
f"Processed {processed}/{total_files} files ({processed/total_files*100:.1f}%)"
|
498 |
+
)
|
499 |
|
500 |
except Exception as e:
|
501 |
logger.error(f"Error during scanning process: {str(e)}")
|
502 |
+
logger.error(traceback.format_exc())
|
503 |
|
504 |
|
505 |
def create_document_routes(
|
lightrag/api/run_with_gunicorn.py
CHANGED
@@ -13,7 +13,7 @@ from dotenv import load_dotenv
|
|
13 |
|
14 |
# Updated to use the .env that is inside the current folder
|
15 |
# This update allows the user to put a different.env file for each lightrag folder
|
16 |
-
load_dotenv(
|
17 |
|
18 |
|
19 |
def check_and_install_dependencies():
|
@@ -140,7 +140,7 @@ def main():
|
|
140 |
|
141 |
# Timeout configuration prioritizes command line arguments
|
142 |
gunicorn_config.timeout = (
|
143 |
-
args.timeout if args.timeout else int(os.getenv("TIMEOUT", 150))
|
144 |
)
|
145 |
|
146 |
# Keepalive configuration
|
|
|
13 |
|
14 |
# Updated to use the .env that is inside the current folder
|
15 |
# This update allows the user to put a different.env file for each lightrag folder
|
16 |
+
load_dotenv()
|
17 |
|
18 |
|
19 |
def check_and_install_dependencies():
|
|
|
140 |
|
141 |
# Timeout configuration prioritizes command line arguments
|
142 |
gunicorn_config.timeout = (
|
143 |
+
args.timeout if args.timeout * 2 else int(os.getenv("TIMEOUT", 150 * 2))
|
144 |
)
|
145 |
|
146 |
# Keepalive configuration
|
lightrag/api/utils_api.py
CHANGED
@@ -9,14 +9,14 @@ import sys
|
|
9 |
import logging
|
10 |
from ascii_colors import ASCIIColors
|
11 |
from lightrag.api import __api_version__
|
12 |
-
from fastapi import HTTPException, Security, Depends, Request
|
13 |
from dotenv import load_dotenv
|
14 |
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
15 |
from starlette.status import HTTP_403_FORBIDDEN
|
16 |
from .auth import auth_handler
|
17 |
|
18 |
# Load environment variables
|
19 |
-
load_dotenv(
|
20 |
|
21 |
global_args = {"main_args": None}
|
22 |
|
@@ -35,19 +35,46 @@ ollama_server_infos = OllamaServerInfos()
|
|
35 |
|
36 |
|
37 |
def get_auth_dependency():
|
38 |
-
|
|
|
39 |
|
40 |
async def dependency(
|
41 |
request: Request,
|
42 |
token: str = Depends(OAuth2PasswordBearer(tokenUrl="login", auto_error=False)),
|
43 |
):
|
44 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
return
|
46 |
|
47 |
-
|
|
|
48 |
return
|
49 |
|
50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
|
52 |
return dependency
|
53 |
|
@@ -338,6 +365,9 @@ def parse_args(is_uvicorn_mode: bool = False) -> argparse.Namespace:
|
|
338 |
"LIGHTRAG_VECTOR_STORAGE", DefaultRAGStorageConfig.VECTOR_STORAGE
|
339 |
)
|
340 |
|
|
|
|
|
|
|
341 |
# Handle openai-ollama special case
|
342 |
if args.llm_binding == "openai-ollama":
|
343 |
args.llm_binding = "openai"
|
@@ -414,8 +444,8 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
|
414 |
ASCIIColors.yellow(f"{args.log_level}")
|
415 |
ASCIIColors.white(" ├─ Verbose Debug: ", end="")
|
416 |
ASCIIColors.yellow(f"{args.verbose}")
|
417 |
-
ASCIIColors.white(" ├─
|
418 |
-
ASCIIColors.yellow(f"{args.
|
419 |
ASCIIColors.white(" └─ API Key: ", end="")
|
420 |
ASCIIColors.yellow("Set" if args.key else "Not Set")
|
421 |
|
@@ -432,8 +462,10 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
|
432 |
ASCIIColors.yellow(f"{args.llm_binding}")
|
433 |
ASCIIColors.white(" ├─ Host: ", end="")
|
434 |
ASCIIColors.yellow(f"{args.llm_binding_host}")
|
435 |
-
ASCIIColors.white("
|
436 |
ASCIIColors.yellow(f"{args.llm_model}")
|
|
|
|
|
437 |
|
438 |
# Embedding Configuration
|
439 |
ASCIIColors.magenta("\n📊 Embedding Configuration:")
|
@@ -448,8 +480,10 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
|
448 |
|
449 |
# RAG Configuration
|
450 |
ASCIIColors.magenta("\n⚙️ RAG Configuration:")
|
451 |
-
ASCIIColors.white(" ├─ Max Async
|
452 |
ASCIIColors.yellow(f"{args.max_async}")
|
|
|
|
|
453 |
ASCIIColors.white(" ├─ Max Tokens: ", end="")
|
454 |
ASCIIColors.yellow(f"{args.max_tokens}")
|
455 |
ASCIIColors.white(" ├─ Max Embed Tokens: ", end="")
|
@@ -458,8 +492,6 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
|
458 |
ASCIIColors.yellow(f"{args.chunk_size}")
|
459 |
ASCIIColors.white(" ├─ Chunk Overlap Size: ", end="")
|
460 |
ASCIIColors.yellow(f"{args.chunk_overlap_size}")
|
461 |
-
ASCIIColors.white(" ├─ History Turns: ", end="")
|
462 |
-
ASCIIColors.yellow(f"{args.history_turns}")
|
463 |
ASCIIColors.white(" ├─ Cosine Threshold: ", end="")
|
464 |
ASCIIColors.yellow(f"{args.cosine_threshold}")
|
465 |
ASCIIColors.white(" ├─ Top-K: ", end="")
|
|
|
9 |
import logging
|
10 |
from ascii_colors import ASCIIColors
|
11 |
from lightrag.api import __api_version__
|
12 |
+
from fastapi import HTTPException, Security, Depends, Request, status
|
13 |
from dotenv import load_dotenv
|
14 |
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
15 |
from starlette.status import HTTP_403_FORBIDDEN
|
16 |
from .auth import auth_handler
|
17 |
|
18 |
# Load environment variables
|
19 |
+
load_dotenv()
|
20 |
|
21 |
global_args = {"main_args": None}
|
22 |
|
|
|
35 |
|
36 |
|
37 |
def get_auth_dependency():
|
38 |
+
# Set default whitelist paths
|
39 |
+
whitelist = os.getenv("WHITELIST_PATHS", "/login,/health").split(",")
|
40 |
|
41 |
async def dependency(
|
42 |
request: Request,
|
43 |
token: str = Depends(OAuth2PasswordBearer(tokenUrl="login", auto_error=False)),
|
44 |
):
|
45 |
+
# Check if authentication is configured
|
46 |
+
auth_configured = bool(
|
47 |
+
os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")
|
48 |
+
)
|
49 |
+
|
50 |
+
# If authentication is not configured, skip all validation
|
51 |
+
if not auth_configured:
|
52 |
return
|
53 |
|
54 |
+
# For configured auth, allow whitelist paths without token
|
55 |
+
if request.url.path in whitelist:
|
56 |
return
|
57 |
|
58 |
+
# Require token for all other paths when auth is configured
|
59 |
+
if not token:
|
60 |
+
raise HTTPException(
|
61 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required"
|
62 |
+
)
|
63 |
+
|
64 |
+
try:
|
65 |
+
token_info = auth_handler.validate_token(token)
|
66 |
+
# Reject guest tokens when authentication is configured
|
67 |
+
if token_info.get("role") == "guest":
|
68 |
+
raise HTTPException(
|
69 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
70 |
+
detail="Authentication required. Guest access not allowed when authentication is configured.",
|
71 |
+
)
|
72 |
+
except Exception:
|
73 |
+
raise HTTPException(
|
74 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
75 |
+
)
|
76 |
+
|
77 |
+
return
|
78 |
|
79 |
return dependency
|
80 |
|
|
|
365 |
"LIGHTRAG_VECTOR_STORAGE", DefaultRAGStorageConfig.VECTOR_STORAGE
|
366 |
)
|
367 |
|
368 |
+
# Get MAX_PARALLEL_INSERT from environment
|
369 |
+
global_args["max_parallel_insert"] = get_env_value("MAX_PARALLEL_INSERT", 2, int)
|
370 |
+
|
371 |
# Handle openai-ollama special case
|
372 |
if args.llm_binding == "openai-ollama":
|
373 |
args.llm_binding = "openai"
|
|
|
444 |
ASCIIColors.yellow(f"{args.log_level}")
|
445 |
ASCIIColors.white(" ├─ Verbose Debug: ", end="")
|
446 |
ASCIIColors.yellow(f"{args.verbose}")
|
447 |
+
ASCIIColors.white(" ├─ History Turns: ", end="")
|
448 |
+
ASCIIColors.yellow(f"{args.history_turns}")
|
449 |
ASCIIColors.white(" └─ API Key: ", end="")
|
450 |
ASCIIColors.yellow("Set" if args.key else "Not Set")
|
451 |
|
|
|
462 |
ASCIIColors.yellow(f"{args.llm_binding}")
|
463 |
ASCIIColors.white(" ├─ Host: ", end="")
|
464 |
ASCIIColors.yellow(f"{args.llm_binding_host}")
|
465 |
+
ASCIIColors.white(" ├─ Model: ", end="")
|
466 |
ASCIIColors.yellow(f"{args.llm_model}")
|
467 |
+
ASCIIColors.white(" └─ Timeout: ", end="")
|
468 |
+
ASCIIColors.yellow(f"{args.timeout if args.timeout else 'None (infinite)'}")
|
469 |
|
470 |
# Embedding Configuration
|
471 |
ASCIIColors.magenta("\n📊 Embedding Configuration:")
|
|
|
480 |
|
481 |
# RAG Configuration
|
482 |
ASCIIColors.magenta("\n⚙️ RAG Configuration:")
|
483 |
+
ASCIIColors.white(" ├─ Max Async for LLM: ", end="")
|
484 |
ASCIIColors.yellow(f"{args.max_async}")
|
485 |
+
ASCIIColors.white(" ├─ Max Parallel Insert: ", end="")
|
486 |
+
ASCIIColors.yellow(f"{global_args['max_parallel_insert']}")
|
487 |
ASCIIColors.white(" ├─ Max Tokens: ", end="")
|
488 |
ASCIIColors.yellow(f"{args.max_tokens}")
|
489 |
ASCIIColors.white(" ├─ Max Embed Tokens: ", end="")
|
|
|
492 |
ASCIIColors.yellow(f"{args.chunk_size}")
|
493 |
ASCIIColors.white(" ├─ Chunk Overlap Size: ", end="")
|
494 |
ASCIIColors.yellow(f"{args.chunk_overlap_size}")
|
|
|
|
|
495 |
ASCIIColors.white(" ├─ Cosine Threshold: ", end="")
|
496 |
ASCIIColors.yellow(f"{args.cosine_threshold}")
|
497 |
ASCIIColors.white(" ├─ Top-K: ", end="")
|
lightrag/api/webui/assets/{index-DwcJE583.js → index-4I5HV9Fr.js}
RENAMED
Binary files a/lightrag/api/webui/assets/index-DwcJE583.js and b/lightrag/api/webui/assets/index-4I5HV9Fr.js differ
|
|
lightrag/api/webui/assets/index-BSOt8Nur.css
ADDED
Binary file (52.9 kB). View file
|
|
lightrag/api/webui/assets/index-BV5s8k-a.css
DELETED
Binary file (48.6 kB)
|
|
lightrag/api/webui/index.html
CHANGED
Binary files a/lightrag/api/webui/index.html and b/lightrag/api/webui/index.html differ
|
|
lightrag/base.py
CHANGED
@@ -257,6 +257,8 @@ class DocProcessingStatus:
|
|
257 |
"""First 100 chars of document content, used for preview"""
|
258 |
content_length: int
|
259 |
"""Total length of document"""
|
|
|
|
|
260 |
status: DocStatus
|
261 |
"""Current processing status"""
|
262 |
created_at: str
|
|
|
257 |
"""First 100 chars of document content, used for preview"""
|
258 |
content_length: int
|
259 |
"""Total length of document"""
|
260 |
+
file_path: str
|
261 |
+
"""File path of the document"""
|
262 |
status: DocStatus
|
263 |
"""Current processing status"""
|
264 |
created_at: str
|
lightrag/kg/json_doc_status_impl.py
CHANGED
@@ -87,6 +87,9 @@ class JsonDocStatusStorage(DocStatusStorage):
|
|
87 |
# If content is missing, use content_summary as content
|
88 |
if "content" not in data and "content_summary" in data:
|
89 |
data["content"] = data["content_summary"]
|
|
|
|
|
|
|
90 |
result[k] = DocProcessingStatus(**data)
|
91 |
except KeyError as e:
|
92 |
logger.error(f"Missing required field for document {k}: {e}")
|
|
|
87 |
# If content is missing, use content_summary as content
|
88 |
if "content" not in data and "content_summary" in data:
|
89 |
data["content"] = data["content_summary"]
|
90 |
+
# If file_path is not in data, use document id as file path
|
91 |
+
if "file_path" not in data:
|
92 |
+
data["file_path"] = "no-file-path"
|
93 |
result[k] = DocProcessingStatus(**data)
|
94 |
except KeyError as e:
|
95 |
logger.error(f"Missing required field for document {k}: {e}")
|
lightrag/kg/networkx_impl.py
CHANGED
@@ -373,6 +373,9 @@ class NetworkXStorage(BaseGraphStorage):
|
|
373 |
# Add edges to result
|
374 |
for edge in subgraph.edges():
|
375 |
source, target = edge
|
|
|
|
|
|
|
376 |
edge_id = f"{source}-{target}"
|
377 |
if edge_id in seen_edges:
|
378 |
continue
|
|
|
373 |
# Add edges to result
|
374 |
for edge in subgraph.edges():
|
375 |
source, target = edge
|
376 |
+
# Esure unique edge_id for undirect graph
|
377 |
+
if source > target:
|
378 |
+
source, target = target, source
|
379 |
edge_id = f"{source}-{target}"
|
380 |
if edge_id in seen_edges:
|
381 |
continue
|
lightrag/kg/postgres_impl.py
CHANGED
@@ -423,6 +423,7 @@ class PGVectorStorage(BaseVectorStorage):
|
|
423 |
"full_doc_id": item["full_doc_id"],
|
424 |
"content": item["content"],
|
425 |
"content_vector": json.dumps(item["__vector__"].tolist()),
|
|
|
426 |
}
|
427 |
except Exception as e:
|
428 |
logger.error(f"Error to prepare upsert,\nsql: {e}\nitem: {item}")
|
@@ -445,6 +446,7 @@ class PGVectorStorage(BaseVectorStorage):
|
|
445 |
"content": item["content"],
|
446 |
"content_vector": json.dumps(item["__vector__"].tolist()),
|
447 |
"chunk_ids": chunk_ids,
|
|
|
448 |
# TODO: add document_id
|
449 |
}
|
450 |
return upsert_sql, data
|
@@ -465,6 +467,7 @@ class PGVectorStorage(BaseVectorStorage):
|
|
465 |
"content": item["content"],
|
466 |
"content_vector": json.dumps(item["__vector__"].tolist()),
|
467 |
"chunk_ids": chunk_ids,
|
|
|
468 |
# TODO: add document_id
|
469 |
}
|
470 |
return upsert_sql, data
|
@@ -732,7 +735,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|
732 |
if result is None or result == []:
|
733 |
return None
|
734 |
else:
|
735 |
-
return
|
736 |
content=result[0]["content"],
|
737 |
content_length=result[0]["content_length"],
|
738 |
content_summary=result[0]["content_summary"],
|
@@ -740,11 +743,34 @@ class PGDocStatusStorage(DocStatusStorage):
|
|
740 |
chunks_count=result[0]["chunks_count"],
|
741 |
created_at=result[0]["created_at"],
|
742 |
updated_at=result[0]["updated_at"],
|
|
|
743 |
)
|
744 |
|
745 |
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
|
746 |
-
"""Get doc_chunks data by
|
747 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
748 |
|
749 |
async def get_status_counts(self) -> dict[str, int]:
|
750 |
"""Get counts of documents in each status"""
|
@@ -774,6 +800,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|
774 |
created_at=element["created_at"],
|
775 |
updated_at=element["updated_at"],
|
776 |
chunks_count=element["chunks_count"],
|
|
|
777 |
)
|
778 |
for element in result
|
779 |
}
|
@@ -793,14 +820,15 @@ class PGDocStatusStorage(DocStatusStorage):
|
|
793 |
if not data:
|
794 |
return
|
795 |
|
796 |
-
sql = """insert into LIGHTRAG_DOC_STATUS(workspace,id,content,content_summary,content_length,chunks_count,status)
|
797 |
-
values($1,$2,$3,$4,$5,$6,$7)
|
798 |
on conflict(id,workspace) do update set
|
799 |
content = EXCLUDED.content,
|
800 |
content_summary = EXCLUDED.content_summary,
|
801 |
content_length = EXCLUDED.content_length,
|
802 |
chunks_count = EXCLUDED.chunks_count,
|
803 |
status = EXCLUDED.status,
|
|
|
804 |
updated_at = CURRENT_TIMESTAMP"""
|
805 |
for k, v in data.items():
|
806 |
# chunks_count is optional
|
@@ -814,6 +842,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|
814 |
"content_length": v["content_length"],
|
815 |
"chunks_count": v["chunks_count"] if "chunks_count" in v else -1,
|
816 |
"status": v["status"],
|
|
|
817 |
},
|
818 |
)
|
819 |
|
@@ -1058,7 +1087,6 @@ class PGGraphStorage(BaseGraphStorage):
|
|
1058 |
|
1059 |
Args:
|
1060 |
query (str): a cypher query to be executed
|
1061 |
-
params (dict): parameters for the query
|
1062 |
|
1063 |
Returns:
|
1064 |
list[dict[str, Any]]: a list of dictionaries containing the result set
|
@@ -1549,6 +1577,7 @@ TABLES = {
|
|
1549 |
tokens INTEGER,
|
1550 |
content TEXT,
|
1551 |
content_vector VECTOR,
|
|
|
1552 |
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
1553 |
update_time TIMESTAMP,
|
1554 |
CONSTRAINT LIGHTRAG_DOC_CHUNKS_PK PRIMARY KEY (workspace, id)
|
@@ -1563,7 +1592,8 @@ TABLES = {
|
|
1563 |
content_vector VECTOR,
|
1564 |
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
1565 |
update_time TIMESTAMP,
|
1566 |
-
|
|
|
1567 |
CONSTRAINT LIGHTRAG_VDB_ENTITY_PK PRIMARY KEY (workspace, id)
|
1568 |
)"""
|
1569 |
},
|
@@ -1577,7 +1607,8 @@ TABLES = {
|
|
1577 |
content_vector VECTOR,
|
1578 |
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
1579 |
update_time TIMESTAMP,
|
1580 |
-
|
|
|
1581 |
CONSTRAINT LIGHTRAG_VDB_RELATION_PK PRIMARY KEY (workspace, id)
|
1582 |
)"""
|
1583 |
},
|
@@ -1602,6 +1633,7 @@ TABLES = {
|
|
1602 |
content_length int4 NULL,
|
1603 |
chunks_count int4 NULL,
|
1604 |
status varchar(64) NULL,
|
|
|
1605 |
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
|
1606 |
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
|
1607 |
CONSTRAINT LIGHTRAG_DOC_STATUS_PK PRIMARY KEY (workspace, id)
|
@@ -1650,35 +1682,38 @@ SQL_TEMPLATES = {
|
|
1650 |
update_time = CURRENT_TIMESTAMP
|
1651 |
""",
|
1652 |
"upsert_chunk": """INSERT INTO LIGHTRAG_DOC_CHUNKS (workspace, id, tokens,
|
1653 |
-
chunk_order_index, full_doc_id, content, content_vector)
|
1654 |
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
1655 |
ON CONFLICT (workspace,id) DO UPDATE
|
1656 |
SET tokens=EXCLUDED.tokens,
|
1657 |
chunk_order_index=EXCLUDED.chunk_order_index,
|
1658 |
full_doc_id=EXCLUDED.full_doc_id,
|
1659 |
content = EXCLUDED.content,
|
1660 |
content_vector=EXCLUDED.content_vector,
|
|
|
1661 |
update_time = CURRENT_TIMESTAMP
|
1662 |
""",
|
1663 |
"upsert_entity": """INSERT INTO LIGHTRAG_VDB_ENTITY (workspace, id, entity_name, content,
|
1664 |
-
content_vector, chunk_ids)
|
1665 |
-
VALUES ($1, $2, $3, $4, $5, $6::varchar[])
|
1666 |
ON CONFLICT (workspace,id) DO UPDATE
|
1667 |
SET entity_name=EXCLUDED.entity_name,
|
1668 |
content=EXCLUDED.content,
|
1669 |
content_vector=EXCLUDED.content_vector,
|
1670 |
chunk_ids=EXCLUDED.chunk_ids,
|
|
|
1671 |
update_time=CURRENT_TIMESTAMP
|
1672 |
""",
|
1673 |
"upsert_relationship": """INSERT INTO LIGHTRAG_VDB_RELATION (workspace, id, source_id,
|
1674 |
-
target_id, content, content_vector, chunk_ids)
|
1675 |
-
VALUES ($1, $2, $3, $4, $5, $6, $7::varchar[])
|
1676 |
ON CONFLICT (workspace,id) DO UPDATE
|
1677 |
SET source_id=EXCLUDED.source_id,
|
1678 |
target_id=EXCLUDED.target_id,
|
1679 |
content=EXCLUDED.content,
|
1680 |
content_vector=EXCLUDED.content_vector,
|
1681 |
chunk_ids=EXCLUDED.chunk_ids,
|
|
|
1682 |
update_time = CURRENT_TIMESTAMP
|
1683 |
""",
|
1684 |
# SQL for VectorStorage
|
|
|
423 |
"full_doc_id": item["full_doc_id"],
|
424 |
"content": item["content"],
|
425 |
"content_vector": json.dumps(item["__vector__"].tolist()),
|
426 |
+
"file_path": item["file_path"],
|
427 |
}
|
428 |
except Exception as e:
|
429 |
logger.error(f"Error to prepare upsert,\nsql: {e}\nitem: {item}")
|
|
|
446 |
"content": item["content"],
|
447 |
"content_vector": json.dumps(item["__vector__"].tolist()),
|
448 |
"chunk_ids": chunk_ids,
|
449 |
+
"file_path": item["file_path"],
|
450 |
# TODO: add document_id
|
451 |
}
|
452 |
return upsert_sql, data
|
|
|
467 |
"content": item["content"],
|
468 |
"content_vector": json.dumps(item["__vector__"].tolist()),
|
469 |
"chunk_ids": chunk_ids,
|
470 |
+
"file_path": item["file_path"],
|
471 |
# TODO: add document_id
|
472 |
}
|
473 |
return upsert_sql, data
|
|
|
735 |
if result is None or result == []:
|
736 |
return None
|
737 |
else:
|
738 |
+
return dict(
|
739 |
content=result[0]["content"],
|
740 |
content_length=result[0]["content_length"],
|
741 |
content_summary=result[0]["content_summary"],
|
|
|
743 |
chunks_count=result[0]["chunks_count"],
|
744 |
created_at=result[0]["created_at"],
|
745 |
updated_at=result[0]["updated_at"],
|
746 |
+
file_path=result[0]["file_path"],
|
747 |
)
|
748 |
|
749 |
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
|
750 |
+
"""Get doc_chunks data by multiple IDs."""
|
751 |
+
if not ids:
|
752 |
+
return []
|
753 |
+
|
754 |
+
sql = "SELECT * FROM LIGHTRAG_DOC_STATUS WHERE workspace=$1 AND id = ANY($2)"
|
755 |
+
params = {"workspace": self.db.workspace, "ids": ids}
|
756 |
+
|
757 |
+
results = await self.db.query(sql, params, True)
|
758 |
+
|
759 |
+
if not results:
|
760 |
+
return []
|
761 |
+
return [
|
762 |
+
{
|
763 |
+
"content": row["content"],
|
764 |
+
"content_length": row["content_length"],
|
765 |
+
"content_summary": row["content_summary"],
|
766 |
+
"status": row["status"],
|
767 |
+
"chunks_count": row["chunks_count"],
|
768 |
+
"created_at": row["created_at"],
|
769 |
+
"updated_at": row["updated_at"],
|
770 |
+
"file_path": row["file_path"],
|
771 |
+
}
|
772 |
+
for row in results
|
773 |
+
]
|
774 |
|
775 |
async def get_status_counts(self) -> dict[str, int]:
|
776 |
"""Get counts of documents in each status"""
|
|
|
800 |
created_at=element["created_at"],
|
801 |
updated_at=element["updated_at"],
|
802 |
chunks_count=element["chunks_count"],
|
803 |
+
file_path=element["file_path"],
|
804 |
)
|
805 |
for element in result
|
806 |
}
|
|
|
820 |
if not data:
|
821 |
return
|
822 |
|
823 |
+
sql = """insert into LIGHTRAG_DOC_STATUS(workspace,id,content,content_summary,content_length,chunks_count,status,file_path)
|
824 |
+
values($1,$2,$3,$4,$5,$6,$7,$8)
|
825 |
on conflict(id,workspace) do update set
|
826 |
content = EXCLUDED.content,
|
827 |
content_summary = EXCLUDED.content_summary,
|
828 |
content_length = EXCLUDED.content_length,
|
829 |
chunks_count = EXCLUDED.chunks_count,
|
830 |
status = EXCLUDED.status,
|
831 |
+
file_path = EXCLUDED.file_path,
|
832 |
updated_at = CURRENT_TIMESTAMP"""
|
833 |
for k, v in data.items():
|
834 |
# chunks_count is optional
|
|
|
842 |
"content_length": v["content_length"],
|
843 |
"chunks_count": v["chunks_count"] if "chunks_count" in v else -1,
|
844 |
"status": v["status"],
|
845 |
+
"file_path": v["file_path"],
|
846 |
},
|
847 |
)
|
848 |
|
|
|
1087 |
|
1088 |
Args:
|
1089 |
query (str): a cypher query to be executed
|
|
|
1090 |
|
1091 |
Returns:
|
1092 |
list[dict[str, Any]]: a list of dictionaries containing the result set
|
|
|
1577 |
tokens INTEGER,
|
1578 |
content TEXT,
|
1579 |
content_vector VECTOR,
|
1580 |
+
file_path VARCHAR(256),
|
1581 |
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
1582 |
update_time TIMESTAMP,
|
1583 |
CONSTRAINT LIGHTRAG_DOC_CHUNKS_PK PRIMARY KEY (workspace, id)
|
|
|
1592 |
content_vector VECTOR,
|
1593 |
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
1594 |
update_time TIMESTAMP,
|
1595 |
+
chunk_ids VARCHAR(255)[] NULL,
|
1596 |
+
file_path TEXT NULL,
|
1597 |
CONSTRAINT LIGHTRAG_VDB_ENTITY_PK PRIMARY KEY (workspace, id)
|
1598 |
)"""
|
1599 |
},
|
|
|
1607 |
content_vector VECTOR,
|
1608 |
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
1609 |
update_time TIMESTAMP,
|
1610 |
+
chunk_ids VARCHAR(255)[] NULL,
|
1611 |
+
file_path TEXT NULL,
|
1612 |
CONSTRAINT LIGHTRAG_VDB_RELATION_PK PRIMARY KEY (workspace, id)
|
1613 |
)"""
|
1614 |
},
|
|
|
1633 |
content_length int4 NULL,
|
1634 |
chunks_count int4 NULL,
|
1635 |
status varchar(64) NULL,
|
1636 |
+
file_path TEXT NULL,
|
1637 |
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
|
1638 |
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
|
1639 |
CONSTRAINT LIGHTRAG_DOC_STATUS_PK PRIMARY KEY (workspace, id)
|
|
|
1682 |
update_time = CURRENT_TIMESTAMP
|
1683 |
""",
|
1684 |
"upsert_chunk": """INSERT INTO LIGHTRAG_DOC_CHUNKS (workspace, id, tokens,
|
1685 |
+
chunk_order_index, full_doc_id, content, content_vector, file_path)
|
1686 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
1687 |
ON CONFLICT (workspace,id) DO UPDATE
|
1688 |
SET tokens=EXCLUDED.tokens,
|
1689 |
chunk_order_index=EXCLUDED.chunk_order_index,
|
1690 |
full_doc_id=EXCLUDED.full_doc_id,
|
1691 |
content = EXCLUDED.content,
|
1692 |
content_vector=EXCLUDED.content_vector,
|
1693 |
+
file_path=EXCLUDED.file_path,
|
1694 |
update_time = CURRENT_TIMESTAMP
|
1695 |
""",
|
1696 |
"upsert_entity": """INSERT INTO LIGHTRAG_VDB_ENTITY (workspace, id, entity_name, content,
|
1697 |
+
content_vector, chunk_ids, file_path)
|
1698 |
+
VALUES ($1, $2, $3, $4, $5, $6::varchar[], $7)
|
1699 |
ON CONFLICT (workspace,id) DO UPDATE
|
1700 |
SET entity_name=EXCLUDED.entity_name,
|
1701 |
content=EXCLUDED.content,
|
1702 |
content_vector=EXCLUDED.content_vector,
|
1703 |
chunk_ids=EXCLUDED.chunk_ids,
|
1704 |
+
file_path=EXCLUDED.file_path,
|
1705 |
update_time=CURRENT_TIMESTAMP
|
1706 |
""",
|
1707 |
"upsert_relationship": """INSERT INTO LIGHTRAG_VDB_RELATION (workspace, id, source_id,
|
1708 |
+
target_id, content, content_vector, chunk_ids, file_path)
|
1709 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7::varchar[], $8)
|
1710 |
ON CONFLICT (workspace,id) DO UPDATE
|
1711 |
SET source_id=EXCLUDED.source_id,
|
1712 |
target_id=EXCLUDED.target_id,
|
1713 |
content=EXCLUDED.content,
|
1714 |
content_vector=EXCLUDED.content_vector,
|
1715 |
chunk_ids=EXCLUDED.chunk_ids,
|
1716 |
+
file_path=EXCLUDED.file_path,
|
1717 |
update_time = CURRENT_TIMESTAMP
|
1718 |
""",
|
1719 |
# SQL for VectorStorage
|
lightrag/kg/shared_storage.py
CHANGED
@@ -41,6 +41,9 @@ _pipeline_status_lock: Optional[LockType] = None
|
|
41 |
_graph_db_lock: Optional[LockType] = None
|
42 |
_data_init_lock: Optional[LockType] = None
|
43 |
|
|
|
|
|
|
|
44 |
|
45 |
class UnifiedLock(Generic[T]):
|
46 |
"""Provide a unified lock interface type for asyncio.Lock and multiprocessing.Lock"""
|
@@ -51,12 +54,14 @@ class UnifiedLock(Generic[T]):
|
|
51 |
is_async: bool,
|
52 |
name: str = "unnamed",
|
53 |
enable_logging: bool = True,
|
|
|
54 |
):
|
55 |
self._lock = lock
|
56 |
self._is_async = is_async
|
57 |
self._pid = os.getpid() # for debug only
|
58 |
self._name = name # for debug only
|
59 |
self._enable_logging = enable_logging # for debug only
|
|
|
60 |
|
61 |
async def __aenter__(self) -> "UnifiedLock[T]":
|
62 |
try:
|
@@ -64,16 +69,39 @@ class UnifiedLock(Generic[T]):
|
|
64 |
f"== Lock == Process {self._pid}: Acquiring lock '{self._name}' (async={self._is_async})",
|
65 |
enable_output=self._enable_logging,
|
66 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
if self._is_async:
|
68 |
await self._lock.acquire()
|
69 |
else:
|
70 |
self._lock.acquire()
|
|
|
71 |
direct_log(
|
72 |
f"== Lock == Process {self._pid}: Lock '{self._name}' acquired (async={self._is_async})",
|
73 |
enable_output=self._enable_logging,
|
74 |
)
|
75 |
return self
|
76 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
direct_log(
|
78 |
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}': {e}",
|
79 |
level="ERROR",
|
@@ -82,15 +110,29 @@ class UnifiedLock(Generic[T]):
|
|
82 |
raise
|
83 |
|
84 |
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
|
85 |
try:
|
86 |
direct_log(
|
87 |
f"== Lock == Process {self._pid}: Releasing lock '{self._name}' (async={self._is_async})",
|
88 |
enable_output=self._enable_logging,
|
89 |
)
|
|
|
|
|
90 |
if self._is_async:
|
91 |
self._lock.release()
|
92 |
else:
|
93 |
self._lock.release()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
94 |
direct_log(
|
95 |
f"== Lock == Process {self._pid}: Lock '{self._name}' released (async={self._is_async})",
|
96 |
enable_output=self._enable_logging,
|
@@ -101,6 +143,31 @@ class UnifiedLock(Generic[T]):
|
|
101 |
level="ERROR",
|
102 |
enable_output=self._enable_logging,
|
103 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
104 |
raise
|
105 |
|
106 |
def __enter__(self) -> "UnifiedLock[T]":
|
@@ -151,51 +218,61 @@ class UnifiedLock(Generic[T]):
|
|
151 |
|
152 |
def get_internal_lock(enable_logging: bool = False) -> UnifiedLock:
|
153 |
"""return unified storage lock for data consistency"""
|
|
|
154 |
return UnifiedLock(
|
155 |
lock=_internal_lock,
|
156 |
is_async=not is_multiprocess,
|
157 |
name="internal_lock",
|
158 |
enable_logging=enable_logging,
|
|
|
159 |
)
|
160 |
|
161 |
|
162 |
def get_storage_lock(enable_logging: bool = False) -> UnifiedLock:
|
163 |
"""return unified storage lock for data consistency"""
|
|
|
164 |
return UnifiedLock(
|
165 |
lock=_storage_lock,
|
166 |
is_async=not is_multiprocess,
|
167 |
name="storage_lock",
|
168 |
enable_logging=enable_logging,
|
|
|
169 |
)
|
170 |
|
171 |
|
172 |
def get_pipeline_status_lock(enable_logging: bool = False) -> UnifiedLock:
|
173 |
"""return unified storage lock for data consistency"""
|
|
|
174 |
return UnifiedLock(
|
175 |
lock=_pipeline_status_lock,
|
176 |
is_async=not is_multiprocess,
|
177 |
name="pipeline_status_lock",
|
178 |
enable_logging=enable_logging,
|
|
|
179 |
)
|
180 |
|
181 |
|
182 |
def get_graph_db_lock(enable_logging: bool = False) -> UnifiedLock:
|
183 |
"""return unified graph database lock for ensuring atomic operations"""
|
|
|
184 |
return UnifiedLock(
|
185 |
lock=_graph_db_lock,
|
186 |
is_async=not is_multiprocess,
|
187 |
name="graph_db_lock",
|
188 |
enable_logging=enable_logging,
|
|
|
189 |
)
|
190 |
|
191 |
|
192 |
def get_data_init_lock(enable_logging: bool = False) -> UnifiedLock:
|
193 |
"""return unified data initialization lock for ensuring atomic data initialization"""
|
|
|
194 |
return UnifiedLock(
|
195 |
lock=_data_init_lock,
|
196 |
is_async=not is_multiprocess,
|
197 |
name="data_init_lock",
|
198 |
enable_logging=enable_logging,
|
|
|
199 |
)
|
200 |
|
201 |
|
@@ -229,7 +306,8 @@ def initialize_share_data(workers: int = 1):
|
|
229 |
_shared_dicts, \
|
230 |
_init_flags, \
|
231 |
_initialized, \
|
232 |
-
_update_flags
|
|
|
233 |
|
234 |
# Check if already initialized
|
235 |
if _initialized:
|
@@ -251,6 +329,16 @@ def initialize_share_data(workers: int = 1):
|
|
251 |
_shared_dicts = _manager.dict()
|
252 |
_init_flags = _manager.dict()
|
253 |
_update_flags = _manager.dict()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
254 |
direct_log(
|
255 |
f"Process {os.getpid()} Shared-Data created for Multiple Process (workers={workers})"
|
256 |
)
|
@@ -264,6 +352,7 @@ def initialize_share_data(workers: int = 1):
|
|
264 |
_shared_dicts = {}
|
265 |
_init_flags = {}
|
266 |
_update_flags = {}
|
|
|
267 |
direct_log(f"Process {os.getpid()} Shared-Data created for Single Process")
|
268 |
|
269 |
# Mark as initialized
|
@@ -458,7 +547,8 @@ def finalize_share_data():
|
|
458 |
_shared_dicts, \
|
459 |
_init_flags, \
|
460 |
_initialized, \
|
461 |
-
_update_flags
|
|
|
462 |
|
463 |
# Check if already initialized
|
464 |
if not _initialized:
|
@@ -523,5 +613,6 @@ def finalize_share_data():
|
|
523 |
_graph_db_lock = None
|
524 |
_data_init_lock = None
|
525 |
_update_flags = None
|
|
|
526 |
|
527 |
direct_log(f"Process {os.getpid()} storage data finalization complete")
|
|
|
41 |
_graph_db_lock: Optional[LockType] = None
|
42 |
_data_init_lock: Optional[LockType] = None
|
43 |
|
44 |
+
# async locks for coroutine synchronization in multiprocess mode
|
45 |
+
_async_locks: Optional[Dict[str, asyncio.Lock]] = None
|
46 |
+
|
47 |
|
48 |
class UnifiedLock(Generic[T]):
|
49 |
"""Provide a unified lock interface type for asyncio.Lock and multiprocessing.Lock"""
|
|
|
54 |
is_async: bool,
|
55 |
name: str = "unnamed",
|
56 |
enable_logging: bool = True,
|
57 |
+
async_lock: Optional[asyncio.Lock] = None,
|
58 |
):
|
59 |
self._lock = lock
|
60 |
self._is_async = is_async
|
61 |
self._pid = os.getpid() # for debug only
|
62 |
self._name = name # for debug only
|
63 |
self._enable_logging = enable_logging # for debug only
|
64 |
+
self._async_lock = async_lock # auxiliary lock for coroutine synchronization
|
65 |
|
66 |
async def __aenter__(self) -> "UnifiedLock[T]":
|
67 |
try:
|
|
|
69 |
f"== Lock == Process {self._pid}: Acquiring lock '{self._name}' (async={self._is_async})",
|
70 |
enable_output=self._enable_logging,
|
71 |
)
|
72 |
+
|
73 |
+
# If in multiprocess mode and async lock exists, acquire it first
|
74 |
+
if not self._is_async and self._async_lock is not None:
|
75 |
+
direct_log(
|
76 |
+
f"== Lock == Process {self._pid}: Acquiring async lock for '{self._name}'",
|
77 |
+
enable_output=self._enable_logging,
|
78 |
+
)
|
79 |
+
await self._async_lock.acquire()
|
80 |
+
direct_log(
|
81 |
+
f"== Lock == Process {self._pid}: Async lock for '{self._name}' acquired",
|
82 |
+
enable_output=self._enable_logging,
|
83 |
+
)
|
84 |
+
|
85 |
+
# Then acquire the main lock
|
86 |
if self._is_async:
|
87 |
await self._lock.acquire()
|
88 |
else:
|
89 |
self._lock.acquire()
|
90 |
+
|
91 |
direct_log(
|
92 |
f"== Lock == Process {self._pid}: Lock '{self._name}' acquired (async={self._is_async})",
|
93 |
enable_output=self._enable_logging,
|
94 |
)
|
95 |
return self
|
96 |
except Exception as e:
|
97 |
+
# If main lock acquisition fails, release the async lock if it was acquired
|
98 |
+
if (
|
99 |
+
not self._is_async
|
100 |
+
and self._async_lock is not None
|
101 |
+
and self._async_lock.locked()
|
102 |
+
):
|
103 |
+
self._async_lock.release()
|
104 |
+
|
105 |
direct_log(
|
106 |
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}': {e}",
|
107 |
level="ERROR",
|
|
|
110 |
raise
|
111 |
|
112 |
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
113 |
+
main_lock_released = False
|
114 |
try:
|
115 |
direct_log(
|
116 |
f"== Lock == Process {self._pid}: Releasing lock '{self._name}' (async={self._is_async})",
|
117 |
enable_output=self._enable_logging,
|
118 |
)
|
119 |
+
|
120 |
+
# Release main lock first
|
121 |
if self._is_async:
|
122 |
self._lock.release()
|
123 |
else:
|
124 |
self._lock.release()
|
125 |
+
|
126 |
+
main_lock_released = True
|
127 |
+
|
128 |
+
# Then release async lock if in multiprocess mode
|
129 |
+
if not self._is_async and self._async_lock is not None:
|
130 |
+
direct_log(
|
131 |
+
f"== Lock == Process {self._pid}: Releasing async lock for '{self._name}'",
|
132 |
+
enable_output=self._enable_logging,
|
133 |
+
)
|
134 |
+
self._async_lock.release()
|
135 |
+
|
136 |
direct_log(
|
137 |
f"== Lock == Process {self._pid}: Lock '{self._name}' released (async={self._is_async})",
|
138 |
enable_output=self._enable_logging,
|
|
|
143 |
level="ERROR",
|
144 |
enable_output=self._enable_logging,
|
145 |
)
|
146 |
+
|
147 |
+
# If main lock release failed but async lock hasn't been released, try to release it
|
148 |
+
if (
|
149 |
+
not main_lock_released
|
150 |
+
and not self._is_async
|
151 |
+
and self._async_lock is not None
|
152 |
+
):
|
153 |
+
try:
|
154 |
+
direct_log(
|
155 |
+
f"== Lock == Process {self._pid}: Attempting to release async lock after main lock failure",
|
156 |
+
level="WARNING",
|
157 |
+
enable_output=self._enable_logging,
|
158 |
+
)
|
159 |
+
self._async_lock.release()
|
160 |
+
direct_log(
|
161 |
+
f"== Lock == Process {self._pid}: Successfully released async lock after main lock failure",
|
162 |
+
enable_output=self._enable_logging,
|
163 |
+
)
|
164 |
+
except Exception as inner_e:
|
165 |
+
direct_log(
|
166 |
+
f"== Lock == Process {self._pid}: Failed to release async lock after main lock failure: {inner_e}",
|
167 |
+
level="ERROR",
|
168 |
+
enable_output=self._enable_logging,
|
169 |
+
)
|
170 |
+
|
171 |
raise
|
172 |
|
173 |
def __enter__(self) -> "UnifiedLock[T]":
|
|
|
218 |
|
219 |
def get_internal_lock(enable_logging: bool = False) -> UnifiedLock:
|
220 |
"""return unified storage lock for data consistency"""
|
221 |
+
async_lock = _async_locks.get("internal_lock") if is_multiprocess else None
|
222 |
return UnifiedLock(
|
223 |
lock=_internal_lock,
|
224 |
is_async=not is_multiprocess,
|
225 |
name="internal_lock",
|
226 |
enable_logging=enable_logging,
|
227 |
+
async_lock=async_lock,
|
228 |
)
|
229 |
|
230 |
|
231 |
def get_storage_lock(enable_logging: bool = False) -> UnifiedLock:
|
232 |
"""return unified storage lock for data consistency"""
|
233 |
+
async_lock = _async_locks.get("storage_lock") if is_multiprocess else None
|
234 |
return UnifiedLock(
|
235 |
lock=_storage_lock,
|
236 |
is_async=not is_multiprocess,
|
237 |
name="storage_lock",
|
238 |
enable_logging=enable_logging,
|
239 |
+
async_lock=async_lock,
|
240 |
)
|
241 |
|
242 |
|
243 |
def get_pipeline_status_lock(enable_logging: bool = False) -> UnifiedLock:
|
244 |
"""return unified storage lock for data consistency"""
|
245 |
+
async_lock = _async_locks.get("pipeline_status_lock") if is_multiprocess else None
|
246 |
return UnifiedLock(
|
247 |
lock=_pipeline_status_lock,
|
248 |
is_async=not is_multiprocess,
|
249 |
name="pipeline_status_lock",
|
250 |
enable_logging=enable_logging,
|
251 |
+
async_lock=async_lock,
|
252 |
)
|
253 |
|
254 |
|
255 |
def get_graph_db_lock(enable_logging: bool = False) -> UnifiedLock:
|
256 |
"""return unified graph database lock for ensuring atomic operations"""
|
257 |
+
async_lock = _async_locks.get("graph_db_lock") if is_multiprocess else None
|
258 |
return UnifiedLock(
|
259 |
lock=_graph_db_lock,
|
260 |
is_async=not is_multiprocess,
|
261 |
name="graph_db_lock",
|
262 |
enable_logging=enable_logging,
|
263 |
+
async_lock=async_lock,
|
264 |
)
|
265 |
|
266 |
|
267 |
def get_data_init_lock(enable_logging: bool = False) -> UnifiedLock:
|
268 |
"""return unified data initialization lock for ensuring atomic data initialization"""
|
269 |
+
async_lock = _async_locks.get("data_init_lock") if is_multiprocess else None
|
270 |
return UnifiedLock(
|
271 |
lock=_data_init_lock,
|
272 |
is_async=not is_multiprocess,
|
273 |
name="data_init_lock",
|
274 |
enable_logging=enable_logging,
|
275 |
+
async_lock=async_lock,
|
276 |
)
|
277 |
|
278 |
|
|
|
306 |
_shared_dicts, \
|
307 |
_init_flags, \
|
308 |
_initialized, \
|
309 |
+
_update_flags, \
|
310 |
+
_async_locks
|
311 |
|
312 |
# Check if already initialized
|
313 |
if _initialized:
|
|
|
329 |
_shared_dicts = _manager.dict()
|
330 |
_init_flags = _manager.dict()
|
331 |
_update_flags = _manager.dict()
|
332 |
+
|
333 |
+
# Initialize async locks for multiprocess mode
|
334 |
+
_async_locks = {
|
335 |
+
"internal_lock": asyncio.Lock(),
|
336 |
+
"storage_lock": asyncio.Lock(),
|
337 |
+
"pipeline_status_lock": asyncio.Lock(),
|
338 |
+
"graph_db_lock": asyncio.Lock(),
|
339 |
+
"data_init_lock": asyncio.Lock(),
|
340 |
+
}
|
341 |
+
|
342 |
direct_log(
|
343 |
f"Process {os.getpid()} Shared-Data created for Multiple Process (workers={workers})"
|
344 |
)
|
|
|
352 |
_shared_dicts = {}
|
353 |
_init_flags = {}
|
354 |
_update_flags = {}
|
355 |
+
_async_locks = None # No need for async locks in single process mode
|
356 |
direct_log(f"Process {os.getpid()} Shared-Data created for Single Process")
|
357 |
|
358 |
# Mark as initialized
|
|
|
547 |
_shared_dicts, \
|
548 |
_init_flags, \
|
549 |
_initialized, \
|
550 |
+
_update_flags, \
|
551 |
+
_async_locks
|
552 |
|
553 |
# Check if already initialized
|
554 |
if not _initialized:
|
|
|
613 |
_graph_db_lock = None
|
614 |
_data_init_lock = None
|
615 |
_update_flags = None
|
616 |
+
_async_locks = None
|
617 |
|
618 |
direct_log(f"Process {os.getpid()} storage data finalization complete")
|
lightrag/lightrag.py
CHANGED
@@ -183,10 +183,10 @@ class LightRAG:
|
|
183 |
embedding_func: EmbeddingFunc | None = field(default=None)
|
184 |
"""Function for computing text embeddings. Must be set before use."""
|
185 |
|
186 |
-
embedding_batch_num: int = field(default=32)
|
187 |
"""Batch size for embedding computations."""
|
188 |
|
189 |
-
embedding_func_max_async: int = field(default=16)
|
190 |
"""Maximum number of concurrent embedding function calls."""
|
191 |
|
192 |
embedding_cache_config: dict[str, Any] = field(
|
@@ -389,20 +389,21 @@ class LightRAG:
|
|
389 |
self.namespace_prefix, NameSpace.VECTOR_STORE_ENTITIES
|
390 |
),
|
391 |
embedding_func=self.embedding_func,
|
392 |
-
meta_fields={"entity_name", "source_id", "content"},
|
393 |
)
|
394 |
self.relationships_vdb: BaseVectorStorage = self.vector_db_storage_cls( # type: ignore
|
395 |
namespace=make_namespace(
|
396 |
self.namespace_prefix, NameSpace.VECTOR_STORE_RELATIONSHIPS
|
397 |
),
|
398 |
embedding_func=self.embedding_func,
|
399 |
-
meta_fields={"src_id", "tgt_id", "source_id", "content"},
|
400 |
)
|
401 |
self.chunks_vdb: BaseVectorStorage = self.vector_db_storage_cls( # type: ignore
|
402 |
namespace=make_namespace(
|
403 |
self.namespace_prefix, NameSpace.VECTOR_STORE_CHUNKS
|
404 |
),
|
405 |
embedding_func=self.embedding_func,
|
|
|
406 |
)
|
407 |
|
408 |
# Initialize document status storage
|
@@ -547,6 +548,7 @@ class LightRAG:
|
|
547 |
split_by_character: str | None = None,
|
548 |
split_by_character_only: bool = False,
|
549 |
ids: str | list[str] | None = None,
|
|
|
550 |
) -> None:
|
551 |
"""Sync Insert documents with checkpoint support
|
552 |
|
@@ -557,10 +559,13 @@ class LightRAG:
|
|
557 |
split_by_character_only: if split_by_character_only is True, split the string by character only, when
|
558 |
split_by_character is None, this parameter is ignored.
|
559 |
ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
|
|
560 |
"""
|
561 |
loop = always_get_an_event_loop()
|
562 |
loop.run_until_complete(
|
563 |
-
self.ainsert(
|
|
|
|
|
564 |
)
|
565 |
|
566 |
async def ainsert(
|
@@ -569,6 +574,7 @@ class LightRAG:
|
|
569 |
split_by_character: str | None = None,
|
570 |
split_by_character_only: bool = False,
|
571 |
ids: str | list[str] | None = None,
|
|
|
572 |
) -> None:
|
573 |
"""Async Insert documents with checkpoint support
|
574 |
|
@@ -579,8 +585,9 @@ class LightRAG:
|
|
579 |
split_by_character_only: if split_by_character_only is True, split the string by character only, when
|
580 |
split_by_character is None, this parameter is ignored.
|
581 |
ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
|
|
582 |
"""
|
583 |
-
await self.apipeline_enqueue_documents(input, ids)
|
584 |
await self.apipeline_process_enqueue_documents(
|
585 |
split_by_character, split_by_character_only
|
586 |
)
|
@@ -654,7 +661,10 @@ class LightRAG:
|
|
654 |
await self._insert_done()
|
655 |
|
656 |
async def apipeline_enqueue_documents(
|
657 |
-
self,
|
|
|
|
|
|
|
658 |
) -> None:
|
659 |
"""
|
660 |
Pipeline for Processing Documents
|
@@ -664,11 +674,30 @@ class LightRAG:
|
|
664 |
3. Generate document initial status
|
665 |
4. Filter out already processed documents
|
666 |
5. Enqueue document in status
|
|
|
|
|
|
|
|
|
|
|
667 |
"""
|
668 |
if isinstance(input, str):
|
669 |
input = [input]
|
670 |
if isinstance(ids, str):
|
671 |
ids = [ids]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
672 |
|
673 |
# 1. Validate ids if provided or generate MD5 hash IDs
|
674 |
if ids is not None:
|
@@ -681,32 +710,59 @@ class LightRAG:
|
|
681 |
raise ValueError("IDs must be unique")
|
682 |
|
683 |
# Generate contents dict of IDs provided by user and documents
|
684 |
-
contents = {
|
|
|
|
|
|
|
685 |
else:
|
686 |
# Clean input text and remove duplicates
|
687 |
-
|
688 |
-
|
689 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
690 |
|
691 |
# 2. Remove duplicate contents
|
692 |
-
unique_contents = {
|
693 |
-
|
694 |
-
|
695 |
-
|
696 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
697 |
}
|
698 |
|
699 |
# 3. Generate document initial status
|
700 |
new_docs: dict[str, Any] = {
|
701 |
id_: {
|
702 |
-
"content": content,
|
703 |
-
"content_summary": get_content_summary(content),
|
704 |
-
"content_length": len(content),
|
705 |
"status": DocStatus.PENDING,
|
|
|
|
|
|
|
706 |
"created_at": datetime.now().isoformat(),
|
707 |
"updated_at": datetime.now().isoformat(),
|
|
|
|
|
|
|
708 |
}
|
709 |
-
for id_,
|
710 |
}
|
711 |
|
712 |
# 4. Filter out already processed documents
|
@@ -841,11 +897,15 @@ class LightRAG:
|
|
841 |
) -> None:
|
842 |
"""Process single document"""
|
843 |
try:
|
|
|
|
|
|
|
844 |
# Generate chunks from document
|
845 |
chunks: dict[str, Any] = {
|
846 |
compute_mdhash_id(dp["content"], prefix="chunk-"): {
|
847 |
**dp,
|
848 |
"full_doc_id": doc_id,
|
|
|
849 |
}
|
850 |
for dp in self.chunking_func(
|
851 |
status_doc.content,
|
@@ -856,6 +916,7 @@ class LightRAG:
|
|
856 |
self.tiktoken_model_name,
|
857 |
)
|
858 |
}
|
|
|
859 |
# Process document (text chunks and full docs) in parallel
|
860 |
# Create tasks with references for potential cancellation
|
861 |
doc_status_task = asyncio.create_task(
|
@@ -863,11 +924,13 @@ class LightRAG:
|
|
863 |
{
|
864 |
doc_id: {
|
865 |
"status": DocStatus.PROCESSING,
|
866 |
-
"
|
867 |
"content": status_doc.content,
|
868 |
"content_summary": status_doc.content_summary,
|
869 |
"content_length": status_doc.content_length,
|
870 |
"created_at": status_doc.created_at,
|
|
|
|
|
871 |
}
|
872 |
}
|
873 |
)
|
@@ -906,6 +969,7 @@ class LightRAG:
|
|
906 |
"content_length": status_doc.content_length,
|
907 |
"created_at": status_doc.created_at,
|
908 |
"updated_at": datetime.now().isoformat(),
|
|
|
909 |
}
|
910 |
}
|
911 |
)
|
@@ -937,6 +1001,7 @@ class LightRAG:
|
|
937 |
"content_length": status_doc.content_length,
|
938 |
"created_at": status_doc.created_at,
|
939 |
"updated_at": datetime.now().isoformat(),
|
|
|
940 |
}
|
941 |
}
|
942 |
)
|
@@ -1063,7 +1128,10 @@ class LightRAG:
|
|
1063 |
loop.run_until_complete(self.ainsert_custom_kg(custom_kg, full_doc_id))
|
1064 |
|
1065 |
async def ainsert_custom_kg(
|
1066 |
-
self,
|
|
|
|
|
|
|
1067 |
) -> None:
|
1068 |
update_storage = False
|
1069 |
try:
|
@@ -1093,6 +1161,7 @@ class LightRAG:
|
|
1093 |
"full_doc_id": full_doc_id
|
1094 |
if full_doc_id is not None
|
1095 |
else source_id,
|
|
|
1096 |
"status": DocStatus.PROCESSED,
|
1097 |
}
|
1098 |
all_chunks_data[chunk_id] = chunk_entry
|
@@ -1197,6 +1266,7 @@ class LightRAG:
|
|
1197 |
"source_id": dp["source_id"],
|
1198 |
"description": dp["description"],
|
1199 |
"entity_type": dp["entity_type"],
|
|
|
1200 |
}
|
1201 |
for dp in all_entities_data
|
1202 |
}
|
@@ -1212,6 +1282,7 @@ class LightRAG:
|
|
1212 |
"keywords": dp["keywords"],
|
1213 |
"description": dp["description"],
|
1214 |
"weight": dp["weight"],
|
|
|
1215 |
}
|
1216 |
for dp in all_relationships_data
|
1217 |
}
|
@@ -1473,8 +1544,7 @@ class LightRAG:
|
|
1473 |
"""
|
1474 |
try:
|
1475 |
# 1. Get the document status and related data
|
1476 |
-
|
1477 |
-
if not doc_status:
|
1478 |
logger.warning(f"Document {doc_id} not found")
|
1479 |
return
|
1480 |
|
@@ -1877,6 +1947,8 @@ class LightRAG:
|
|
1877 |
|
1878 |
# 2. Update entity information in the graph
|
1879 |
new_node_data = {**node_data, **updated_data}
|
|
|
|
|
1880 |
if "entity_name" in new_node_data:
|
1881 |
del new_node_data[
|
1882 |
"entity_name"
|
@@ -1893,7 +1965,7 @@ class LightRAG:
|
|
1893 |
|
1894 |
# Store relationships that need to be updated
|
1895 |
relations_to_update = []
|
1896 |
-
|
1897 |
# Get all edges related to the original entity
|
1898 |
edges = await self.chunk_entity_relation_graph.get_node_edges(
|
1899 |
entity_name
|
@@ -1905,6 +1977,12 @@ class LightRAG:
|
|
1905 |
source, target
|
1906 |
)
|
1907 |
if edge_data:
|
|
|
|
|
|
|
|
|
|
|
|
|
1908 |
if source == entity_name:
|
1909 |
await self.chunk_entity_relation_graph.upsert_edge(
|
1910 |
new_entity_name, target, edge_data
|
@@ -1930,6 +2008,12 @@ class LightRAG:
|
|
1930 |
f"Deleted old entity '{entity_name}' and its vector embedding from database"
|
1931 |
)
|
1932 |
|
|
|
|
|
|
|
|
|
|
|
|
|
1933 |
# Update relationship vector representations
|
1934 |
for src, tgt, edge_data in relations_to_update:
|
1935 |
description = edge_data.get("description", "")
|
@@ -2220,7 +2304,6 @@ class LightRAG:
|
|
2220 |
"""Synchronously create a new entity.
|
2221 |
|
2222 |
Creates a new entity in the knowledge graph and adds it to the vector database.
|
2223 |
-
|
2224 |
Args:
|
2225 |
entity_name: Name of the new entity
|
2226 |
entity_data: Dictionary containing entity attributes, e.g. {"description": "description", "entity_type": "type"}
|
@@ -2429,39 +2512,21 @@ class LightRAG:
|
|
2429 |
# 4. Get all relationships of the source entities
|
2430 |
all_relations = []
|
2431 |
for entity_name in source_entities:
|
2432 |
-
# Get all relationships
|
2433 |
-
|
2434 |
entity_name
|
2435 |
)
|
2436 |
-
if
|
2437 |
-
for src, tgt in
|
2438 |
# Ensure src is the current entity
|
2439 |
if src == entity_name:
|
2440 |
edge_data = await self.chunk_entity_relation_graph.get_edge(
|
2441 |
src, tgt
|
2442 |
)
|
2443 |
-
all_relations.append((
|
2444 |
-
|
2445 |
-
# Get all relationships where this entity is the target
|
2446 |
-
incoming_edges = []
|
2447 |
-
all_labels = await self.chunk_entity_relation_graph.get_all_labels()
|
2448 |
-
for label in all_labels:
|
2449 |
-
if label == entity_name:
|
2450 |
-
continue
|
2451 |
-
node_edges = await self.chunk_entity_relation_graph.get_node_edges(
|
2452 |
-
label
|
2453 |
-
)
|
2454 |
-
for src, tgt in node_edges or []:
|
2455 |
-
if tgt == entity_name:
|
2456 |
-
incoming_edges.append((src, tgt))
|
2457 |
-
|
2458 |
-
for src, tgt in incoming_edges:
|
2459 |
-
edge_data = await self.chunk_entity_relation_graph.get_edge(
|
2460 |
-
src, tgt
|
2461 |
-
)
|
2462 |
-
all_relations.append(("incoming", src, tgt, edge_data))
|
2463 |
|
2464 |
# 5. Create or update the target entity
|
|
|
2465 |
if not target_exists:
|
2466 |
await self.chunk_entity_relation_graph.upsert_node(
|
2467 |
target_entity, merged_entity_data
|
@@ -2475,8 +2540,11 @@ class LightRAG:
|
|
2475 |
|
2476 |
# 6. Recreate all relationships, pointing to the target entity
|
2477 |
relation_updates = {} # Track relationships that need to be merged
|
|
|
2478 |
|
2479 |
-
for
|
|
|
|
|
2480 |
new_src = target_entity if src in source_entities else src
|
2481 |
new_tgt = target_entity if tgt in source_entities else tgt
|
2482 |
|
@@ -2521,6 +2589,12 @@ class LightRAG:
|
|
2521 |
f"Created or updated relationship: {rel_data['src']} -> {rel_data['tgt']}"
|
2522 |
)
|
2523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
2524 |
# 7. Update entity vector representation
|
2525 |
description = merged_entity_data.get("description", "")
|
2526 |
source_id = merged_entity_data.get("source_id", "")
|
@@ -2583,19 +2657,6 @@ class LightRAG:
|
|
2583 |
entity_id = compute_mdhash_id(entity_name, prefix="ent-")
|
2584 |
await self.entities_vdb.delete([entity_id])
|
2585 |
|
2586 |
-
# Also ensure any relationships specific to this entity are deleted from vector DB
|
2587 |
-
# This is a safety check, as these should have been transformed to the target entity already
|
2588 |
-
entity_relation_prefix = compute_mdhash_id(entity_name, prefix="rel-")
|
2589 |
-
relations_with_entity = await self.relationships_vdb.search_by_prefix(
|
2590 |
-
entity_relation_prefix
|
2591 |
-
)
|
2592 |
-
if relations_with_entity:
|
2593 |
-
relation_ids = [r["id"] for r in relations_with_entity]
|
2594 |
-
await self.relationships_vdb.delete(relation_ids)
|
2595 |
-
logger.info(
|
2596 |
-
f"Deleted {len(relation_ids)} relation records for entity '{entity_name}' from vector database"
|
2597 |
-
)
|
2598 |
-
|
2599 |
logger.info(
|
2600 |
f"Deleted source entity '{entity_name}' and its vector embedding from database"
|
2601 |
)
|
|
|
183 |
embedding_func: EmbeddingFunc | None = field(default=None)
|
184 |
"""Function for computing text embeddings. Must be set before use."""
|
185 |
|
186 |
+
embedding_batch_num: int = field(default=int(os.getenv("EMBEDDING_BATCH_NUM", 32)))
|
187 |
"""Batch size for embedding computations."""
|
188 |
|
189 |
+
embedding_func_max_async: int = field(default=int(os.getenv("EMBEDDING_FUNC_MAX_ASYNC", 16)))
|
190 |
"""Maximum number of concurrent embedding function calls."""
|
191 |
|
192 |
embedding_cache_config: dict[str, Any] = field(
|
|
|
389 |
self.namespace_prefix, NameSpace.VECTOR_STORE_ENTITIES
|
390 |
),
|
391 |
embedding_func=self.embedding_func,
|
392 |
+
meta_fields={"entity_name", "source_id", "content", "file_path"},
|
393 |
)
|
394 |
self.relationships_vdb: BaseVectorStorage = self.vector_db_storage_cls( # type: ignore
|
395 |
namespace=make_namespace(
|
396 |
self.namespace_prefix, NameSpace.VECTOR_STORE_RELATIONSHIPS
|
397 |
),
|
398 |
embedding_func=self.embedding_func,
|
399 |
+
meta_fields={"src_id", "tgt_id", "source_id", "content", "file_path"},
|
400 |
)
|
401 |
self.chunks_vdb: BaseVectorStorage = self.vector_db_storage_cls( # type: ignore
|
402 |
namespace=make_namespace(
|
403 |
self.namespace_prefix, NameSpace.VECTOR_STORE_CHUNKS
|
404 |
),
|
405 |
embedding_func=self.embedding_func,
|
406 |
+
meta_fields={"full_doc_id", "content", "file_path"},
|
407 |
)
|
408 |
|
409 |
# Initialize document status storage
|
|
|
548 |
split_by_character: str | None = None,
|
549 |
split_by_character_only: bool = False,
|
550 |
ids: str | list[str] | None = None,
|
551 |
+
file_paths: str | list[str] | None = None,
|
552 |
) -> None:
|
553 |
"""Sync Insert documents with checkpoint support
|
554 |
|
|
|
559 |
split_by_character_only: if split_by_character_only is True, split the string by character only, when
|
560 |
split_by_character is None, this parameter is ignored.
|
561 |
ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
562 |
+
file_paths: single string of the file path or list of file paths, used for citation
|
563 |
"""
|
564 |
loop = always_get_an_event_loop()
|
565 |
loop.run_until_complete(
|
566 |
+
self.ainsert(
|
567 |
+
input, split_by_character, split_by_character_only, ids, file_paths
|
568 |
+
)
|
569 |
)
|
570 |
|
571 |
async def ainsert(
|
|
|
574 |
split_by_character: str | None = None,
|
575 |
split_by_character_only: bool = False,
|
576 |
ids: str | list[str] | None = None,
|
577 |
+
file_paths: str | list[str] | None = None,
|
578 |
) -> None:
|
579 |
"""Async Insert documents with checkpoint support
|
580 |
|
|
|
585 |
split_by_character_only: if split_by_character_only is True, split the string by character only, when
|
586 |
split_by_character is None, this parameter is ignored.
|
587 |
ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
588 |
+
file_paths: list of file paths corresponding to each document, used for citation
|
589 |
"""
|
590 |
+
await self.apipeline_enqueue_documents(input, ids, file_paths)
|
591 |
await self.apipeline_process_enqueue_documents(
|
592 |
split_by_character, split_by_character_only
|
593 |
)
|
|
|
661 |
await self._insert_done()
|
662 |
|
663 |
async def apipeline_enqueue_documents(
|
664 |
+
self,
|
665 |
+
input: str | list[str],
|
666 |
+
ids: list[str] | None = None,
|
667 |
+
file_paths: str | list[str] | None = None,
|
668 |
) -> None:
|
669 |
"""
|
670 |
Pipeline for Processing Documents
|
|
|
674 |
3. Generate document initial status
|
675 |
4. Filter out already processed documents
|
676 |
5. Enqueue document in status
|
677 |
+
|
678 |
+
Args:
|
679 |
+
input: Single document string or list of document strings
|
680 |
+
ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated
|
681 |
+
file_paths: list of file paths corresponding to each document, used for citation
|
682 |
"""
|
683 |
if isinstance(input, str):
|
684 |
input = [input]
|
685 |
if isinstance(ids, str):
|
686 |
ids = [ids]
|
687 |
+
if isinstance(file_paths, str):
|
688 |
+
file_paths = [file_paths]
|
689 |
+
|
690 |
+
# If file_paths is provided, ensure it matches the number of documents
|
691 |
+
if file_paths is not None:
|
692 |
+
if isinstance(file_paths, str):
|
693 |
+
file_paths = [file_paths]
|
694 |
+
if len(file_paths) != len(input):
|
695 |
+
raise ValueError(
|
696 |
+
"Number of file paths must match the number of documents"
|
697 |
+
)
|
698 |
+
else:
|
699 |
+
# If no file paths provided, use placeholder
|
700 |
+
file_paths = ["unknown_source"] * len(input)
|
701 |
|
702 |
# 1. Validate ids if provided or generate MD5 hash IDs
|
703 |
if ids is not None:
|
|
|
710 |
raise ValueError("IDs must be unique")
|
711 |
|
712 |
# Generate contents dict of IDs provided by user and documents
|
713 |
+
contents = {
|
714 |
+
id_: {"content": doc, "file_path": path}
|
715 |
+
for id_, doc, path in zip(ids, input, file_paths)
|
716 |
+
}
|
717 |
else:
|
718 |
# Clean input text and remove duplicates
|
719 |
+
cleaned_input = [
|
720 |
+
(clean_text(doc), path) for doc, path in zip(input, file_paths)
|
721 |
+
]
|
722 |
+
unique_content_with_paths = {}
|
723 |
+
|
724 |
+
# Keep track of unique content and their paths
|
725 |
+
for content, path in cleaned_input:
|
726 |
+
if content not in unique_content_with_paths:
|
727 |
+
unique_content_with_paths[content] = path
|
728 |
+
|
729 |
+
# Generate contents dict of MD5 hash IDs and documents with paths
|
730 |
+
contents = {
|
731 |
+
compute_mdhash_id(content, prefix="doc-"): {
|
732 |
+
"content": content,
|
733 |
+
"file_path": path,
|
734 |
+
}
|
735 |
+
for content, path in unique_content_with_paths.items()
|
736 |
+
}
|
737 |
|
738 |
# 2. Remove duplicate contents
|
739 |
+
unique_contents = {}
|
740 |
+
for id_, content_data in contents.items():
|
741 |
+
content = content_data["content"]
|
742 |
+
file_path = content_data["file_path"]
|
743 |
+
if content not in unique_contents:
|
744 |
+
unique_contents[content] = (id_, file_path)
|
745 |
+
|
746 |
+
# Reconstruct contents with unique content
|
747 |
+
contents = {
|
748 |
+
id_: {"content": content, "file_path": file_path}
|
749 |
+
for content, (id_, file_path) in unique_contents.items()
|
750 |
}
|
751 |
|
752 |
# 3. Generate document initial status
|
753 |
new_docs: dict[str, Any] = {
|
754 |
id_: {
|
|
|
|
|
|
|
755 |
"status": DocStatus.PENDING,
|
756 |
+
"content": content_data["content"],
|
757 |
+
"content_summary": get_content_summary(content_data["content"]),
|
758 |
+
"content_length": len(content_data["content"]),
|
759 |
"created_at": datetime.now().isoformat(),
|
760 |
"updated_at": datetime.now().isoformat(),
|
761 |
+
"file_path": content_data[
|
762 |
+
"file_path"
|
763 |
+
], # Store file path in document status
|
764 |
}
|
765 |
+
for id_, content_data in contents.items()
|
766 |
}
|
767 |
|
768 |
# 4. Filter out already processed documents
|
|
|
897 |
) -> None:
|
898 |
"""Process single document"""
|
899 |
try:
|
900 |
+
# Get file path from status document
|
901 |
+
file_path = getattr(status_doc, "file_path", "unknown_source")
|
902 |
+
|
903 |
# Generate chunks from document
|
904 |
chunks: dict[str, Any] = {
|
905 |
compute_mdhash_id(dp["content"], prefix="chunk-"): {
|
906 |
**dp,
|
907 |
"full_doc_id": doc_id,
|
908 |
+
"file_path": file_path, # Add file path to each chunk
|
909 |
}
|
910 |
for dp in self.chunking_func(
|
911 |
status_doc.content,
|
|
|
916 |
self.tiktoken_model_name,
|
917 |
)
|
918 |
}
|
919 |
+
|
920 |
# Process document (text chunks and full docs) in parallel
|
921 |
# Create tasks with references for potential cancellation
|
922 |
doc_status_task = asyncio.create_task(
|
|
|
924 |
{
|
925 |
doc_id: {
|
926 |
"status": DocStatus.PROCESSING,
|
927 |
+
"chunks_count": len(chunks),
|
928 |
"content": status_doc.content,
|
929 |
"content_summary": status_doc.content_summary,
|
930 |
"content_length": status_doc.content_length,
|
931 |
"created_at": status_doc.created_at,
|
932 |
+
"updated_at": datetime.now().isoformat(),
|
933 |
+
"file_path": file_path,
|
934 |
}
|
935 |
}
|
936 |
)
|
|
|
969 |
"content_length": status_doc.content_length,
|
970 |
"created_at": status_doc.created_at,
|
971 |
"updated_at": datetime.now().isoformat(),
|
972 |
+
"file_path": file_path,
|
973 |
}
|
974 |
}
|
975 |
)
|
|
|
1001 |
"content_length": status_doc.content_length,
|
1002 |
"created_at": status_doc.created_at,
|
1003 |
"updated_at": datetime.now().isoformat(),
|
1004 |
+
"file_path": file_path,
|
1005 |
}
|
1006 |
}
|
1007 |
)
|
|
|
1128 |
loop.run_until_complete(self.ainsert_custom_kg(custom_kg, full_doc_id))
|
1129 |
|
1130 |
async def ainsert_custom_kg(
|
1131 |
+
self,
|
1132 |
+
custom_kg: dict[str, Any],
|
1133 |
+
full_doc_id: str = None,
|
1134 |
+
file_path: str = "custom_kg",
|
1135 |
) -> None:
|
1136 |
update_storage = False
|
1137 |
try:
|
|
|
1161 |
"full_doc_id": full_doc_id
|
1162 |
if full_doc_id is not None
|
1163 |
else source_id,
|
1164 |
+
"file_path": file_path, # Add file path
|
1165 |
"status": DocStatus.PROCESSED,
|
1166 |
}
|
1167 |
all_chunks_data[chunk_id] = chunk_entry
|
|
|
1266 |
"source_id": dp["source_id"],
|
1267 |
"description": dp["description"],
|
1268 |
"entity_type": dp["entity_type"],
|
1269 |
+
"file_path": file_path, # Add file path
|
1270 |
}
|
1271 |
for dp in all_entities_data
|
1272 |
}
|
|
|
1282 |
"keywords": dp["keywords"],
|
1283 |
"description": dp["description"],
|
1284 |
"weight": dp["weight"],
|
1285 |
+
"file_path": file_path, # Add file path
|
1286 |
}
|
1287 |
for dp in all_relationships_data
|
1288 |
}
|
|
|
1544 |
"""
|
1545 |
try:
|
1546 |
# 1. Get the document status and related data
|
1547 |
+
if not await self.doc_status.get_by_id(doc_id):
|
|
|
1548 |
logger.warning(f"Document {doc_id} not found")
|
1549 |
return
|
1550 |
|
|
|
1947 |
|
1948 |
# 2. Update entity information in the graph
|
1949 |
new_node_data = {**node_data, **updated_data}
|
1950 |
+
new_node_data["entity_id"] = new_entity_name
|
1951 |
+
|
1952 |
if "entity_name" in new_node_data:
|
1953 |
del new_node_data[
|
1954 |
"entity_name"
|
|
|
1965 |
|
1966 |
# Store relationships that need to be updated
|
1967 |
relations_to_update = []
|
1968 |
+
relations_to_delete = []
|
1969 |
# Get all edges related to the original entity
|
1970 |
edges = await self.chunk_entity_relation_graph.get_node_edges(
|
1971 |
entity_name
|
|
|
1977 |
source, target
|
1978 |
)
|
1979 |
if edge_data:
|
1980 |
+
relations_to_delete.append(
|
1981 |
+
compute_mdhash_id(source + target, prefix="rel-")
|
1982 |
+
)
|
1983 |
+
relations_to_delete.append(
|
1984 |
+
compute_mdhash_id(target + source, prefix="rel-")
|
1985 |
+
)
|
1986 |
if source == entity_name:
|
1987 |
await self.chunk_entity_relation_graph.upsert_edge(
|
1988 |
new_entity_name, target, edge_data
|
|
|
2008 |
f"Deleted old entity '{entity_name}' and its vector embedding from database"
|
2009 |
)
|
2010 |
|
2011 |
+
# Delete old relation records from vector database
|
2012 |
+
await self.relationships_vdb.delete(relations_to_delete)
|
2013 |
+
logger.info(
|
2014 |
+
f"Deleted {len(relations_to_delete)} relation records for entity '{entity_name}' from vector database"
|
2015 |
+
)
|
2016 |
+
|
2017 |
# Update relationship vector representations
|
2018 |
for src, tgt, edge_data in relations_to_update:
|
2019 |
description = edge_data.get("description", "")
|
|
|
2304 |
"""Synchronously create a new entity.
|
2305 |
|
2306 |
Creates a new entity in the knowledge graph and adds it to the vector database.
|
|
|
2307 |
Args:
|
2308 |
entity_name: Name of the new entity
|
2309 |
entity_data: Dictionary containing entity attributes, e.g. {"description": "description", "entity_type": "type"}
|
|
|
2512 |
# 4. Get all relationships of the source entities
|
2513 |
all_relations = []
|
2514 |
for entity_name in source_entities:
|
2515 |
+
# Get all relationships of the source entities
|
2516 |
+
edges = await self.chunk_entity_relation_graph.get_node_edges(
|
2517 |
entity_name
|
2518 |
)
|
2519 |
+
if edges:
|
2520 |
+
for src, tgt in edges:
|
2521 |
# Ensure src is the current entity
|
2522 |
if src == entity_name:
|
2523 |
edge_data = await self.chunk_entity_relation_graph.get_edge(
|
2524 |
src, tgt
|
2525 |
)
|
2526 |
+
all_relations.append((src, tgt, edge_data))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2527 |
|
2528 |
# 5. Create or update the target entity
|
2529 |
+
merged_entity_data["entity_id"] = target_entity
|
2530 |
if not target_exists:
|
2531 |
await self.chunk_entity_relation_graph.upsert_node(
|
2532 |
target_entity, merged_entity_data
|
|
|
2540 |
|
2541 |
# 6. Recreate all relationships, pointing to the target entity
|
2542 |
relation_updates = {} # Track relationships that need to be merged
|
2543 |
+
relations_to_delete = []
|
2544 |
|
2545 |
+
for src, tgt, edge_data in all_relations:
|
2546 |
+
relations_to_delete.append(compute_mdhash_id(src + tgt, prefix="rel-"))
|
2547 |
+
relations_to_delete.append(compute_mdhash_id(tgt + src, prefix="rel-"))
|
2548 |
new_src = target_entity if src in source_entities else src
|
2549 |
new_tgt = target_entity if tgt in source_entities else tgt
|
2550 |
|
|
|
2589 |
f"Created or updated relationship: {rel_data['src']} -> {rel_data['tgt']}"
|
2590 |
)
|
2591 |
|
2592 |
+
# Delete relationships records from vector database
|
2593 |
+
await self.relationships_vdb.delete(relations_to_delete)
|
2594 |
+
logger.info(
|
2595 |
+
f"Deleted {len(relations_to_delete)} relation records for entity '{entity_name}' from vector database"
|
2596 |
+
)
|
2597 |
+
|
2598 |
# 7. Update entity vector representation
|
2599 |
description = merged_entity_data.get("description", "")
|
2600 |
source_id = merged_entity_data.get("source_id", "")
|
|
|
2657 |
entity_id = compute_mdhash_id(entity_name, prefix="ent-")
|
2658 |
await self.entities_vdb.delete([entity_id])
|
2659 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2660 |
logger.info(
|
2661 |
f"Deleted source entity '{entity_name}' and its vector embedding from database"
|
2662 |
)
|
lightrag/llm/hf.py
CHANGED
@@ -138,16 +138,31 @@ async def hf_model_complete(
|
|
138 |
|
139 |
|
140 |
async def hf_embed(texts: list[str], tokenizer, embed_model) -> np.ndarray:
|
141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
142 |
encoded_texts = tokenizer(
|
143 |
texts, return_tensors="pt", padding=True, truncation=True
|
144 |
).to(device)
|
|
|
|
|
145 |
with torch.no_grad():
|
146 |
outputs = embed_model(
|
147 |
input_ids=encoded_texts["input_ids"],
|
148 |
attention_mask=encoded_texts["attention_mask"],
|
149 |
)
|
150 |
embeddings = outputs.last_hidden_state.mean(dim=1)
|
|
|
|
|
151 |
if embeddings.dtype == torch.bfloat16:
|
152 |
return embeddings.detach().to(torch.float32).cpu().numpy()
|
153 |
else:
|
|
|
138 |
|
139 |
|
140 |
async def hf_embed(texts: list[str], tokenizer, embed_model) -> np.ndarray:
|
141 |
+
# Detect the appropriate device
|
142 |
+
if torch.cuda.is_available():
|
143 |
+
device = next(embed_model.parameters()).device # Use CUDA if available
|
144 |
+
elif torch.backends.mps.is_available():
|
145 |
+
device = torch.device("mps") # Use MPS for Apple Silicon
|
146 |
+
else:
|
147 |
+
device = torch.device("cpu") # Fallback to CPU
|
148 |
+
|
149 |
+
# Move the model to the detected device
|
150 |
+
embed_model = embed_model.to(device)
|
151 |
+
|
152 |
+
# Tokenize the input texts and move them to the same device
|
153 |
encoded_texts = tokenizer(
|
154 |
texts, return_tensors="pt", padding=True, truncation=True
|
155 |
).to(device)
|
156 |
+
|
157 |
+
# Perform inference
|
158 |
with torch.no_grad():
|
159 |
outputs = embed_model(
|
160 |
input_ids=encoded_texts["input_ids"],
|
161 |
attention_mask=encoded_texts["attention_mask"],
|
162 |
)
|
163 |
embeddings = outputs.last_hidden_state.mean(dim=1)
|
164 |
+
|
165 |
+
# Convert embeddings to NumPy
|
166 |
if embeddings.dtype == torch.bfloat16:
|
167 |
return embeddings.detach().to(torch.float32).cpu().numpy()
|
168 |
else:
|
lightrag/operate.py
CHANGED
@@ -138,6 +138,7 @@ async def _handle_entity_relation_summary(
|
|
138 |
async def _handle_single_entity_extraction(
|
139 |
record_attributes: list[str],
|
140 |
chunk_key: str,
|
|
|
141 |
):
|
142 |
if len(record_attributes) < 4 or record_attributes[0] != '"entity"':
|
143 |
return None
|
@@ -171,13 +172,14 @@ async def _handle_single_entity_extraction(
|
|
171 |
entity_type=entity_type,
|
172 |
description=entity_description,
|
173 |
source_id=chunk_key,
|
174 |
-
|
175 |
)
|
176 |
|
177 |
|
178 |
async def _handle_single_relationship_extraction(
|
179 |
record_attributes: list[str],
|
180 |
chunk_key: str,
|
|
|
181 |
):
|
182 |
if len(record_attributes) < 5 or record_attributes[0] != '"relationship"':
|
183 |
return None
|
@@ -199,7 +201,7 @@ async def _handle_single_relationship_extraction(
|
|
199 |
description=edge_description,
|
200 |
keywords=edge_keywords,
|
201 |
source_id=edge_source_id,
|
202 |
-
|
203 |
)
|
204 |
|
205 |
|
@@ -213,6 +215,7 @@ async def _merge_nodes_then_upsert(
|
|
213 |
already_entity_types = []
|
214 |
already_source_ids = []
|
215 |
already_description = []
|
|
|
216 |
|
217 |
already_node = await knowledge_graph_inst.get_node(entity_name)
|
218 |
if already_node is not None:
|
@@ -220,6 +223,9 @@ async def _merge_nodes_then_upsert(
|
|
220 |
already_source_ids.extend(
|
221 |
split_string_by_multi_markers(already_node["source_id"], [GRAPH_FIELD_SEP])
|
222 |
)
|
|
|
|
|
|
|
223 |
already_description.append(already_node["description"])
|
224 |
|
225 |
entity_type = sorted(
|
@@ -235,6 +241,11 @@ async def _merge_nodes_then_upsert(
|
|
235 |
source_id = GRAPH_FIELD_SEP.join(
|
236 |
set([dp["source_id"] for dp in nodes_data] + already_source_ids)
|
237 |
)
|
|
|
|
|
|
|
|
|
|
|
238 |
description = await _handle_entity_relation_summary(
|
239 |
entity_name, description, global_config
|
240 |
)
|
@@ -243,6 +254,7 @@ async def _merge_nodes_then_upsert(
|
|
243 |
entity_type=entity_type,
|
244 |
description=description,
|
245 |
source_id=source_id,
|
|
|
246 |
)
|
247 |
await knowledge_graph_inst.upsert_node(
|
248 |
entity_name,
|
@@ -263,6 +275,7 @@ async def _merge_edges_then_upsert(
|
|
263 |
already_source_ids = []
|
264 |
already_description = []
|
265 |
already_keywords = []
|
|
|
266 |
|
267 |
if await knowledge_graph_inst.has_edge(src_id, tgt_id):
|
268 |
already_edge = await knowledge_graph_inst.get_edge(src_id, tgt_id)
|
@@ -279,6 +292,14 @@ async def _merge_edges_then_upsert(
|
|
279 |
)
|
280 |
)
|
281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
282 |
# Get description with empty string default if missing or None
|
283 |
if already_edge.get("description") is not None:
|
284 |
already_description.append(already_edge["description"])
|
@@ -315,6 +336,12 @@ async def _merge_edges_then_upsert(
|
|
315 |
+ already_source_ids
|
316 |
)
|
317 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
318 |
|
319 |
for need_insert_id in [src_id, tgt_id]:
|
320 |
if not (await knowledge_graph_inst.has_node(need_insert_id)):
|
@@ -325,6 +352,7 @@ async def _merge_edges_then_upsert(
|
|
325 |
"source_id": source_id,
|
326 |
"description": description,
|
327 |
"entity_type": "UNKNOWN",
|
|
|
328 |
},
|
329 |
)
|
330 |
description = await _handle_entity_relation_summary(
|
@@ -338,6 +366,7 @@ async def _merge_edges_then_upsert(
|
|
338 |
description=description,
|
339 |
keywords=keywords,
|
340 |
source_id=source_id,
|
|
|
341 |
),
|
342 |
)
|
343 |
|
@@ -347,6 +376,7 @@ async def _merge_edges_then_upsert(
|
|
347 |
description=description,
|
348 |
keywords=keywords,
|
349 |
source_id=source_id,
|
|
|
350 |
)
|
351 |
|
352 |
return edge_data
|
@@ -456,11 +486,14 @@ async def extract_entities(
|
|
456 |
else:
|
457 |
return await use_llm_func(input_text)
|
458 |
|
459 |
-
async def _process_extraction_result(
|
|
|
|
|
460 |
"""Process a single extraction result (either initial or gleaning)
|
461 |
Args:
|
462 |
result (str): The extraction result to process
|
463 |
chunk_key (str): The chunk key for source tracking
|
|
|
464 |
Returns:
|
465 |
tuple: (nodes_dict, edges_dict) containing the extracted entities and relationships
|
466 |
"""
|
@@ -482,14 +515,14 @@ async def extract_entities(
|
|
482 |
)
|
483 |
|
484 |
if_entities = await _handle_single_entity_extraction(
|
485 |
-
record_attributes, chunk_key
|
486 |
)
|
487 |
if if_entities is not None:
|
488 |
maybe_nodes[if_entities["entity_name"]].append(if_entities)
|
489 |
continue
|
490 |
|
491 |
if_relation = await _handle_single_relationship_extraction(
|
492 |
-
record_attributes, chunk_key
|
493 |
)
|
494 |
if if_relation is not None:
|
495 |
maybe_edges[(if_relation["src_id"], if_relation["tgt_id"])].append(
|
@@ -508,6 +541,8 @@ async def extract_entities(
|
|
508 |
chunk_key = chunk_key_dp[0]
|
509 |
chunk_dp = chunk_key_dp[1]
|
510 |
content = chunk_dp["content"]
|
|
|
|
|
511 |
|
512 |
# Get initial extraction
|
513 |
hint_prompt = entity_extract_prompt.format(
|
@@ -517,9 +552,9 @@ async def extract_entities(
|
|
517 |
final_result = await _user_llm_func_with_cache(hint_prompt)
|
518 |
history = pack_user_ass_to_openai_messages(hint_prompt, final_result)
|
519 |
|
520 |
-
# Process initial extraction
|
521 |
maybe_nodes, maybe_edges = await _process_extraction_result(
|
522 |
-
final_result, chunk_key
|
523 |
)
|
524 |
|
525 |
# Process additional gleaning results
|
@@ -530,9 +565,9 @@ async def extract_entities(
|
|
530 |
|
531 |
history += pack_user_ass_to_openai_messages(continue_prompt, glean_result)
|
532 |
|
533 |
-
# Process gleaning result separately
|
534 |
glean_nodes, glean_edges = await _process_extraction_result(
|
535 |
-
glean_result, chunk_key
|
536 |
)
|
537 |
|
538 |
# Merge results
|
@@ -637,9 +672,7 @@ async def extract_entities(
|
|
637 |
"entity_type": dp["entity_type"],
|
638 |
"content": f"{dp['entity_name']}\n{dp['description']}",
|
639 |
"source_id": dp["source_id"],
|
640 |
-
"
|
641 |
-
"created_at": dp.get("metadata", {}).get("created_at", time.time())
|
642 |
-
},
|
643 |
}
|
644 |
for dp in all_entities_data
|
645 |
}
|
@@ -653,9 +686,7 @@ async def extract_entities(
|
|
653 |
"keywords": dp["keywords"],
|
654 |
"content": f"{dp['src_id']}\t{dp['tgt_id']}\n{dp['keywords']}\n{dp['description']}",
|
655 |
"source_id": dp["source_id"],
|
656 |
-
"
|
657 |
-
"created_at": dp.get("metadata", {}).get("created_at", time.time())
|
658 |
-
},
|
659 |
}
|
660 |
for dp in all_relationships_data
|
661 |
}
|
@@ -1232,12 +1263,17 @@ async def _get_node_data(
|
|
1232 |
"description",
|
1233 |
"rank",
|
1234 |
"created_at",
|
|
|
1235 |
]
|
1236 |
]
|
1237 |
for i, n in enumerate(node_datas):
|
1238 |
created_at = n.get("created_at", "UNKNOWN")
|
1239 |
if isinstance(created_at, (int, float)):
|
1240 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
|
|
|
|
|
|
|
|
1241 |
entites_section_list.append(
|
1242 |
[
|
1243 |
i,
|
@@ -1246,6 +1282,7 @@ async def _get_node_data(
|
|
1246 |
n.get("description", "UNKNOWN"),
|
1247 |
n["rank"],
|
1248 |
created_at,
|
|
|
1249 |
]
|
1250 |
)
|
1251 |
entities_context = list_of_list_to_csv(entites_section_list)
|
@@ -1260,6 +1297,7 @@ async def _get_node_data(
|
|
1260 |
"weight",
|
1261 |
"rank",
|
1262 |
"created_at",
|
|
|
1263 |
]
|
1264 |
]
|
1265 |
for i, e in enumerate(use_relations):
|
@@ -1267,6 +1305,10 @@ async def _get_node_data(
|
|
1267 |
# Convert timestamp to readable format
|
1268 |
if isinstance(created_at, (int, float)):
|
1269 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
|
|
|
|
|
|
|
|
1270 |
relations_section_list.append(
|
1271 |
[
|
1272 |
i,
|
@@ -1277,6 +1319,7 @@ async def _get_node_data(
|
|
1277 |
e["weight"],
|
1278 |
e["rank"],
|
1279 |
created_at,
|
|
|
1280 |
]
|
1281 |
)
|
1282 |
relations_context = list_of_list_to_csv(relations_section_list)
|
@@ -1492,6 +1535,7 @@ async def _get_edge_data(
|
|
1492 |
"weight",
|
1493 |
"rank",
|
1494 |
"created_at",
|
|
|
1495 |
]
|
1496 |
]
|
1497 |
for i, e in enumerate(edge_datas):
|
@@ -1499,6 +1543,10 @@ async def _get_edge_data(
|
|
1499 |
# Convert timestamp to readable format
|
1500 |
if isinstance(created_at, (int, float)):
|
1501 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
|
|
|
|
|
|
|
|
1502 |
relations_section_list.append(
|
1503 |
[
|
1504 |
i,
|
@@ -1509,16 +1557,23 @@ async def _get_edge_data(
|
|
1509 |
e["weight"],
|
1510 |
e["rank"],
|
1511 |
created_at,
|
|
|
1512 |
]
|
1513 |
)
|
1514 |
relations_context = list_of_list_to_csv(relations_section_list)
|
1515 |
|
1516 |
-
entites_section_list = [
|
|
|
|
|
1517 |
for i, n in enumerate(use_entities):
|
1518 |
-
created_at =
|
1519 |
# Convert timestamp to readable format
|
1520 |
if isinstance(created_at, (int, float)):
|
1521 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
|
|
|
|
|
|
|
|
1522 |
entites_section_list.append(
|
1523 |
[
|
1524 |
i,
|
@@ -1527,6 +1582,7 @@ async def _get_edge_data(
|
|
1527 |
n.get("description", "UNKNOWN"),
|
1528 |
n["rank"],
|
1529 |
created_at,
|
|
|
1530 |
]
|
1531 |
)
|
1532 |
entities_context = list_of_list_to_csv(entites_section_list)
|
@@ -1882,13 +1938,14 @@ async def kg_query_with_keywords(
|
|
1882 |
len_of_prompts = len(encode_string_by_tiktoken(query + sys_prompt))
|
1883 |
logger.debug(f"[kg_query_with_keywords]Prompt Tokens: {len_of_prompts}")
|
1884 |
|
|
|
1885 |
response = await use_model_func(
|
1886 |
query,
|
1887 |
system_prompt=sys_prompt,
|
1888 |
stream=query_param.stream,
|
1889 |
)
|
1890 |
|
1891 |
-
#
|
1892 |
if isinstance(response, str) and len(response) > len(sys_prompt):
|
1893 |
response = (
|
1894 |
response.replace(sys_prompt, "")
|
|
|
138 |
async def _handle_single_entity_extraction(
|
139 |
record_attributes: list[str],
|
140 |
chunk_key: str,
|
141 |
+
file_path: str = "unknown_source",
|
142 |
):
|
143 |
if len(record_attributes) < 4 or record_attributes[0] != '"entity"':
|
144 |
return None
|
|
|
172 |
entity_type=entity_type,
|
173 |
description=entity_description,
|
174 |
source_id=chunk_key,
|
175 |
+
file_path=file_path,
|
176 |
)
|
177 |
|
178 |
|
179 |
async def _handle_single_relationship_extraction(
|
180 |
record_attributes: list[str],
|
181 |
chunk_key: str,
|
182 |
+
file_path: str = "unknown_source",
|
183 |
):
|
184 |
if len(record_attributes) < 5 or record_attributes[0] != '"relationship"':
|
185 |
return None
|
|
|
201 |
description=edge_description,
|
202 |
keywords=edge_keywords,
|
203 |
source_id=edge_source_id,
|
204 |
+
file_path=file_path,
|
205 |
)
|
206 |
|
207 |
|
|
|
215 |
already_entity_types = []
|
216 |
already_source_ids = []
|
217 |
already_description = []
|
218 |
+
already_file_paths = []
|
219 |
|
220 |
already_node = await knowledge_graph_inst.get_node(entity_name)
|
221 |
if already_node is not None:
|
|
|
223 |
already_source_ids.extend(
|
224 |
split_string_by_multi_markers(already_node["source_id"], [GRAPH_FIELD_SEP])
|
225 |
)
|
226 |
+
already_file_paths.extend(
|
227 |
+
split_string_by_multi_markers(already_node["file_path"], [GRAPH_FIELD_SEP])
|
228 |
+
)
|
229 |
already_description.append(already_node["description"])
|
230 |
|
231 |
entity_type = sorted(
|
|
|
241 |
source_id = GRAPH_FIELD_SEP.join(
|
242 |
set([dp["source_id"] for dp in nodes_data] + already_source_ids)
|
243 |
)
|
244 |
+
file_path = GRAPH_FIELD_SEP.join(
|
245 |
+
set([dp["file_path"] for dp in nodes_data] + already_file_paths)
|
246 |
+
)
|
247 |
+
|
248 |
+
logger.debug(f"file_path: {file_path}")
|
249 |
description = await _handle_entity_relation_summary(
|
250 |
entity_name, description, global_config
|
251 |
)
|
|
|
254 |
entity_type=entity_type,
|
255 |
description=description,
|
256 |
source_id=source_id,
|
257 |
+
file_path=file_path,
|
258 |
)
|
259 |
await knowledge_graph_inst.upsert_node(
|
260 |
entity_name,
|
|
|
275 |
already_source_ids = []
|
276 |
already_description = []
|
277 |
already_keywords = []
|
278 |
+
already_file_paths = []
|
279 |
|
280 |
if await knowledge_graph_inst.has_edge(src_id, tgt_id):
|
281 |
already_edge = await knowledge_graph_inst.get_edge(src_id, tgt_id)
|
|
|
292 |
)
|
293 |
)
|
294 |
|
295 |
+
# Get file_path with empty string default if missing or None
|
296 |
+
if already_edge.get("file_path") is not None:
|
297 |
+
already_file_paths.extend(
|
298 |
+
split_string_by_multi_markers(
|
299 |
+
already_edge["file_path"], [GRAPH_FIELD_SEP]
|
300 |
+
)
|
301 |
+
)
|
302 |
+
|
303 |
# Get description with empty string default if missing or None
|
304 |
if already_edge.get("description") is not None:
|
305 |
already_description.append(already_edge["description"])
|
|
|
336 |
+ already_source_ids
|
337 |
)
|
338 |
)
|
339 |
+
file_path = GRAPH_FIELD_SEP.join(
|
340 |
+
set(
|
341 |
+
[dp["file_path"] for dp in edges_data if dp.get("file_path")]
|
342 |
+
+ already_file_paths
|
343 |
+
)
|
344 |
+
)
|
345 |
|
346 |
for need_insert_id in [src_id, tgt_id]:
|
347 |
if not (await knowledge_graph_inst.has_node(need_insert_id)):
|
|
|
352 |
"source_id": source_id,
|
353 |
"description": description,
|
354 |
"entity_type": "UNKNOWN",
|
355 |
+
"file_path": file_path,
|
356 |
},
|
357 |
)
|
358 |
description = await _handle_entity_relation_summary(
|
|
|
366 |
description=description,
|
367 |
keywords=keywords,
|
368 |
source_id=source_id,
|
369 |
+
file_path=file_path,
|
370 |
),
|
371 |
)
|
372 |
|
|
|
376 |
description=description,
|
377 |
keywords=keywords,
|
378 |
source_id=source_id,
|
379 |
+
file_path=file_path,
|
380 |
)
|
381 |
|
382 |
return edge_data
|
|
|
486 |
else:
|
487 |
return await use_llm_func(input_text)
|
488 |
|
489 |
+
async def _process_extraction_result(
|
490 |
+
result: str, chunk_key: str, file_path: str = "unknown_source"
|
491 |
+
):
|
492 |
"""Process a single extraction result (either initial or gleaning)
|
493 |
Args:
|
494 |
result (str): The extraction result to process
|
495 |
chunk_key (str): The chunk key for source tracking
|
496 |
+
file_path (str): The file path for citation
|
497 |
Returns:
|
498 |
tuple: (nodes_dict, edges_dict) containing the extracted entities and relationships
|
499 |
"""
|
|
|
515 |
)
|
516 |
|
517 |
if_entities = await _handle_single_entity_extraction(
|
518 |
+
record_attributes, chunk_key, file_path
|
519 |
)
|
520 |
if if_entities is not None:
|
521 |
maybe_nodes[if_entities["entity_name"]].append(if_entities)
|
522 |
continue
|
523 |
|
524 |
if_relation = await _handle_single_relationship_extraction(
|
525 |
+
record_attributes, chunk_key, file_path
|
526 |
)
|
527 |
if if_relation is not None:
|
528 |
maybe_edges[(if_relation["src_id"], if_relation["tgt_id"])].append(
|
|
|
541 |
chunk_key = chunk_key_dp[0]
|
542 |
chunk_dp = chunk_key_dp[1]
|
543 |
content = chunk_dp["content"]
|
544 |
+
# Get file path from chunk data or use default
|
545 |
+
file_path = chunk_dp.get("file_path", "unknown_source")
|
546 |
|
547 |
# Get initial extraction
|
548 |
hint_prompt = entity_extract_prompt.format(
|
|
|
552 |
final_result = await _user_llm_func_with_cache(hint_prompt)
|
553 |
history = pack_user_ass_to_openai_messages(hint_prompt, final_result)
|
554 |
|
555 |
+
# Process initial extraction with file path
|
556 |
maybe_nodes, maybe_edges = await _process_extraction_result(
|
557 |
+
final_result, chunk_key, file_path
|
558 |
)
|
559 |
|
560 |
# Process additional gleaning results
|
|
|
565 |
|
566 |
history += pack_user_ass_to_openai_messages(continue_prompt, glean_result)
|
567 |
|
568 |
+
# Process gleaning result separately with file path
|
569 |
glean_nodes, glean_edges = await _process_extraction_result(
|
570 |
+
glean_result, chunk_key, file_path
|
571 |
)
|
572 |
|
573 |
# Merge results
|
|
|
672 |
"entity_type": dp["entity_type"],
|
673 |
"content": f"{dp['entity_name']}\n{dp['description']}",
|
674 |
"source_id": dp["source_id"],
|
675 |
+
"file_path": dp.get("file_path", "unknown_source"),
|
|
|
|
|
676 |
}
|
677 |
for dp in all_entities_data
|
678 |
}
|
|
|
686 |
"keywords": dp["keywords"],
|
687 |
"content": f"{dp['src_id']}\t{dp['tgt_id']}\n{dp['keywords']}\n{dp['description']}",
|
688 |
"source_id": dp["source_id"],
|
689 |
+
"file_path": dp.get("file_path", "unknown_source"),
|
|
|
|
|
690 |
}
|
691 |
for dp in all_relationships_data
|
692 |
}
|
|
|
1263 |
"description",
|
1264 |
"rank",
|
1265 |
"created_at",
|
1266 |
+
"file_path",
|
1267 |
]
|
1268 |
]
|
1269 |
for i, n in enumerate(node_datas):
|
1270 |
created_at = n.get("created_at", "UNKNOWN")
|
1271 |
if isinstance(created_at, (int, float)):
|
1272 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
1273 |
+
|
1274 |
+
# Get file path from node data
|
1275 |
+
file_path = n.get("file_path", "unknown_source")
|
1276 |
+
|
1277 |
entites_section_list.append(
|
1278 |
[
|
1279 |
i,
|
|
|
1282 |
n.get("description", "UNKNOWN"),
|
1283 |
n["rank"],
|
1284 |
created_at,
|
1285 |
+
file_path,
|
1286 |
]
|
1287 |
)
|
1288 |
entities_context = list_of_list_to_csv(entites_section_list)
|
|
|
1297 |
"weight",
|
1298 |
"rank",
|
1299 |
"created_at",
|
1300 |
+
"file_path",
|
1301 |
]
|
1302 |
]
|
1303 |
for i, e in enumerate(use_relations):
|
|
|
1305 |
# Convert timestamp to readable format
|
1306 |
if isinstance(created_at, (int, float)):
|
1307 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
1308 |
+
|
1309 |
+
# Get file path from edge data
|
1310 |
+
file_path = e.get("file_path", "unknown_source")
|
1311 |
+
|
1312 |
relations_section_list.append(
|
1313 |
[
|
1314 |
i,
|
|
|
1319 |
e["weight"],
|
1320 |
e["rank"],
|
1321 |
created_at,
|
1322 |
+
file_path,
|
1323 |
]
|
1324 |
)
|
1325 |
relations_context = list_of_list_to_csv(relations_section_list)
|
|
|
1535 |
"weight",
|
1536 |
"rank",
|
1537 |
"created_at",
|
1538 |
+
"file_path",
|
1539 |
]
|
1540 |
]
|
1541 |
for i, e in enumerate(edge_datas):
|
|
|
1543 |
# Convert timestamp to readable format
|
1544 |
if isinstance(created_at, (int, float)):
|
1545 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
1546 |
+
|
1547 |
+
# Get file path from edge data
|
1548 |
+
file_path = e.get("file_path", "unknown_source")
|
1549 |
+
|
1550 |
relations_section_list.append(
|
1551 |
[
|
1552 |
i,
|
|
|
1557 |
e["weight"],
|
1558 |
e["rank"],
|
1559 |
created_at,
|
1560 |
+
file_path,
|
1561 |
]
|
1562 |
)
|
1563 |
relations_context = list_of_list_to_csv(relations_section_list)
|
1564 |
|
1565 |
+
entites_section_list = [
|
1566 |
+
["id", "entity", "type", "description", "rank", "created_at", "file_path"]
|
1567 |
+
]
|
1568 |
for i, n in enumerate(use_entities):
|
1569 |
+
created_at = n.get("created_at", "Unknown")
|
1570 |
# Convert timestamp to readable format
|
1571 |
if isinstance(created_at, (int, float)):
|
1572 |
created_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(created_at))
|
1573 |
+
|
1574 |
+
# Get file path from node data
|
1575 |
+
file_path = n.get("file_path", "unknown_source")
|
1576 |
+
|
1577 |
entites_section_list.append(
|
1578 |
[
|
1579 |
i,
|
|
|
1582 |
n.get("description", "UNKNOWN"),
|
1583 |
n["rank"],
|
1584 |
created_at,
|
1585 |
+
file_path,
|
1586 |
]
|
1587 |
)
|
1588 |
entities_context = list_of_list_to_csv(entites_section_list)
|
|
|
1938 |
len_of_prompts = len(encode_string_by_tiktoken(query + sys_prompt))
|
1939 |
logger.debug(f"[kg_query_with_keywords]Prompt Tokens: {len_of_prompts}")
|
1940 |
|
1941 |
+
# 6. Generate response
|
1942 |
response = await use_model_func(
|
1943 |
query,
|
1944 |
system_prompt=sys_prompt,
|
1945 |
stream=query_param.stream,
|
1946 |
)
|
1947 |
|
1948 |
+
# Clean up response content
|
1949 |
if isinstance(response, str) and len(response) > len(sys_prompt):
|
1950 |
response = (
|
1951 |
response.replace(sys_prompt, "")
|
lightrag/prompt.py
CHANGED
@@ -61,7 +61,7 @@ Text:
|
|
61 |
```
|
62 |
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.
|
63 |
|
64 |
-
Then Taylor did something unexpected. They paused beside Jordan and, for a moment, observed the device with something akin to reverence.
|
65 |
|
66 |
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.
|
67 |
|
@@ -92,7 +92,7 @@ Among the hardest hit, Nexon Technologies saw its stock plummet by 7.8% after re
|
|
92 |
|
93 |
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.
|
94 |
|
95 |
-
Financial experts are closely watching the Federal Reserve
|
96 |
```
|
97 |
|
98 |
Output:
|
@@ -222,6 +222,7 @@ When handling relationships with timestamps:
|
|
222 |
- Use markdown formatting with appropriate section headings
|
223 |
- Please respond in the same language as the user's question.
|
224 |
- Ensure the response maintains continuity with the conversation history.
|
|
|
225 |
- If you don't know the answer, just say so.
|
226 |
- Do not make anything up. Do not include information not provided by the Knowledge Base."""
|
227 |
|
@@ -319,6 +320,7 @@ When handling content with timestamps:
|
|
319 |
- Use markdown formatting with appropriate section headings
|
320 |
- Please respond in the same language as the user's question.
|
321 |
- Ensure the response maintains continuity with the conversation history.
|
|
|
322 |
- If you don't know the answer, just say so.
|
323 |
- Do not include information not provided by the Document Chunks."""
|
324 |
|
@@ -378,8 +380,8 @@ When handling information with timestamps:
|
|
378 |
- Use markdown formatting with appropriate section headings
|
379 |
- Please respond in the same language as the user's question.
|
380 |
- Ensure the response maintains continuity with the conversation history.
|
381 |
-
- Organize answer in
|
382 |
- Use clear and descriptive section titles that reflect the content
|
383 |
-
- List up to 5 most important reference sources at the end under "References"
|
384 |
- If you don't know the answer, just say so. Do not make anything up.
|
385 |
- Do not include information not provided by the Data Sources."""
|
|
|
61 |
```
|
62 |
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.
|
63 |
|
64 |
+
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."
|
65 |
|
66 |
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.
|
67 |
|
|
|
92 |
|
93 |
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.
|
94 |
|
95 |
+
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.
|
96 |
```
|
97 |
|
98 |
Output:
|
|
|
222 |
- Use markdown formatting with appropriate section headings
|
223 |
- Please respond in the same language as the user's question.
|
224 |
- Ensure the response maintains continuity with the conversation history.
|
225 |
+
- 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)
|
226 |
- If you don't know the answer, just say so.
|
227 |
- Do not make anything up. Do not include information not provided by the Knowledge Base."""
|
228 |
|
|
|
320 |
- Use markdown formatting with appropriate section headings
|
321 |
- Please respond in the same language as the user's question.
|
322 |
- Ensure the response maintains continuity with the conversation history.
|
323 |
+
- 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)
|
324 |
- If you don't know the answer, just say so.
|
325 |
- Do not include information not provided by the Document Chunks."""
|
326 |
|
|
|
380 |
- Use markdown formatting with appropriate section headings
|
381 |
- Please respond in the same language as the user's question.
|
382 |
- Ensure the response maintains continuity with the conversation history.
|
383 |
+
- Organize answer in sections focusing on one main point or aspect of the answer
|
384 |
- Use clear and descriptive section titles that reflect the content
|
385 |
+
- 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)
|
386 |
- If you don't know the answer, just say so. Do not make anything up.
|
387 |
- Do not include information not provided by the Data Sources."""
|
lightrag/utils.py
CHANGED
@@ -109,15 +109,17 @@ def setup_logger(
|
|
109 |
logger_name: str,
|
110 |
level: str = "INFO",
|
111 |
add_filter: bool = False,
|
112 |
-
log_file_path: str = None,
|
|
|
113 |
):
|
114 |
-
"""Set up a logger with console and file handlers
|
115 |
|
116 |
Args:
|
117 |
logger_name: Name of the logger to set up
|
118 |
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
119 |
add_filter: Whether to add LightragPathFilter to the logger
|
120 |
-
log_file_path: Path to the log file. If None,
|
|
|
121 |
"""
|
122 |
# Configure formatters
|
123 |
detailed_formatter = logging.Formatter(
|
@@ -125,18 +127,6 @@ def setup_logger(
|
|
125 |
)
|
126 |
simple_formatter = logging.Formatter("%(levelname)s: %(message)s")
|
127 |
|
128 |
-
# Get log file path
|
129 |
-
if log_file_path is None:
|
130 |
-
log_dir = os.getenv("LOG_DIR", os.getcwd())
|
131 |
-
log_file_path = os.path.abspath(os.path.join(log_dir, "lightrag.log"))
|
132 |
-
|
133 |
-
# Ensure log directory exists
|
134 |
-
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
|
135 |
-
|
136 |
-
# Get log file max size and backup count from environment variables
|
137 |
-
log_max_bytes = int(os.getenv("LOG_MAX_BYTES", 10485760)) # Default 10MB
|
138 |
-
log_backup_count = int(os.getenv("LOG_BACKUP_COUNT", 5)) # Default 5 backups
|
139 |
-
|
140 |
logger_instance = logging.getLogger(logger_name)
|
141 |
logger_instance.setLevel(level)
|
142 |
logger_instance.handlers = [] # Clear existing handlers
|
@@ -148,16 +138,34 @@ def setup_logger(
|
|
148 |
console_handler.setLevel(level)
|
149 |
logger_instance.addHandler(console_handler)
|
150 |
|
151 |
-
# Add file handler
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
161 |
|
162 |
# Add path filter if requested
|
163 |
if add_filter:
|
|
|
109 |
logger_name: str,
|
110 |
level: str = "INFO",
|
111 |
add_filter: bool = False,
|
112 |
+
log_file_path: str | None = None,
|
113 |
+
enable_file_logging: bool = True,
|
114 |
):
|
115 |
+
"""Set up a logger with console and optionally file handlers
|
116 |
|
117 |
Args:
|
118 |
logger_name: Name of the logger to set up
|
119 |
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
120 |
add_filter: Whether to add LightragPathFilter to the logger
|
121 |
+
log_file_path: Path to the log file. If None and file logging is enabled, defaults to lightrag.log in LOG_DIR or cwd
|
122 |
+
enable_file_logging: Whether to enable logging to a file (defaults to True)
|
123 |
"""
|
124 |
# Configure formatters
|
125 |
detailed_formatter = logging.Formatter(
|
|
|
127 |
)
|
128 |
simple_formatter = logging.Formatter("%(levelname)s: %(message)s")
|
129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
130 |
logger_instance = logging.getLogger(logger_name)
|
131 |
logger_instance.setLevel(level)
|
132 |
logger_instance.handlers = [] # Clear existing handlers
|
|
|
138 |
console_handler.setLevel(level)
|
139 |
logger_instance.addHandler(console_handler)
|
140 |
|
141 |
+
# Add file handler by default unless explicitly disabled
|
142 |
+
if enable_file_logging:
|
143 |
+
# Get log file path
|
144 |
+
if log_file_path is None:
|
145 |
+
log_dir = os.getenv("LOG_DIR", os.getcwd())
|
146 |
+
log_file_path = os.path.abspath(os.path.join(log_dir, "lightrag.log"))
|
147 |
+
|
148 |
+
# Ensure log directory exists
|
149 |
+
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
|
150 |
+
|
151 |
+
# Get log file max size and backup count from environment variables
|
152 |
+
log_max_bytes = int(os.getenv("LOG_MAX_BYTES", 10485760)) # Default 10MB
|
153 |
+
log_backup_count = int(os.getenv("LOG_BACKUP_COUNT", 5)) # Default 5 backups
|
154 |
+
|
155 |
+
try:
|
156 |
+
# Add file handler
|
157 |
+
file_handler = logging.handlers.RotatingFileHandler(
|
158 |
+
filename=log_file_path,
|
159 |
+
maxBytes=log_max_bytes,
|
160 |
+
backupCount=log_backup_count,
|
161 |
+
encoding="utf-8",
|
162 |
+
)
|
163 |
+
file_handler.setFormatter(detailed_formatter)
|
164 |
+
file_handler.setLevel(level)
|
165 |
+
logger_instance.addHandler(file_handler)
|
166 |
+
except PermissionError as e:
|
167 |
+
logger.warning(f"Could not create log file at {log_file_path}: {str(e)}")
|
168 |
+
logger.warning("Continuing with console logging only")
|
169 |
|
170 |
# Add path filter if requested
|
171 |
if add_filter:
|
lightrag_webui/bun.lock
CHANGED
@@ -40,9 +40,11 @@
|
|
40 |
"react": "^19.0.0",
|
41 |
"react-dom": "^19.0.0",
|
42 |
"react-dropzone": "^14.3.6",
|
|
|
43 |
"react-i18next": "^15.4.1",
|
44 |
"react-markdown": "^9.1.0",
|
45 |
"react-number-format": "^5.4.3",
|
|
|
46 |
"react-syntax-highlighter": "^15.6.1",
|
47 |
"rehype-react": "^8.0.0",
|
48 |
"remark-gfm": "^4.0.1",
|
@@ -418,6 +420,8 @@
|
|
418 |
|
419 |
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.2.3" } }, "sha512-054h79ipETRfjtsCW9qJK8Ipof67Pw9bodFWmkfkaUaRiIQ1dIV2VTlheshlBx3mpKr0KeK8VqnMMCtgN9rQtw=="],
|
420 |
|
|
|
|
|
421 |
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
422 |
|
423 |
"@types/estree": ["@types/[email protected]", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
|
@@ -566,6 +570,8 @@
|
|
566 |
|
567 |
"convert-source-map": ["[email protected]", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
568 |
|
|
|
|
|
569 |
"cosmiconfig": ["[email protected]", "", { "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=="],
|
570 |
|
571 |
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
@@ -1102,6 +1108,8 @@
|
|
1102 |
|
1103 |
"react-dropzone": ["[email protected]", "", { "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=="],
|
1104 |
|
|
|
|
|
1105 |
"react-i18next": ["[email protected]", "", { "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=="],
|
1106 |
|
1107 |
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
@@ -1114,6 +1122,10 @@
|
|
1114 |
|
1115 |
"react-remove-scroll-bar": ["[email protected]", "", { "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=="],
|
1116 |
|
|
|
|
|
|
|
|
|
1117 |
"react-select": ["[email protected]", "", { "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=="],
|
1118 |
|
1119 |
"react-style-singleton": ["[email protected]", "", { "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 @@
|
|
1164 |
|
1165 |
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
1166 |
|
|
|
|
|
1167 |
"set-function-length": ["[email protected]", "", { "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=="],
|
1168 |
|
1169 |
"set-function-name": ["[email protected]", "", { "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 @@
|
|
1234 |
|
1235 |
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
1236 |
|
|
|
|
|
1237 |
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
1238 |
|
1239 |
"typed-array-buffer": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
|
|
40 |
"react": "^19.0.0",
|
41 |
"react-dom": "^19.0.0",
|
42 |
"react-dropzone": "^14.3.6",
|
43 |
+
"react-error-boundary": "^5.0.0",
|
44 |
"react-i18next": "^15.4.1",
|
45 |
"react-markdown": "^9.1.0",
|
46 |
"react-number-format": "^5.4.3",
|
47 |
+
"react-router-dom": "^7.3.0",
|
48 |
"react-syntax-highlighter": "^15.6.1",
|
49 |
"rehype-react": "^8.0.0",
|
50 |
"remark-gfm": "^4.0.1",
|
|
|
420 |
|
421 |
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.2.3" } }, "sha512-054h79ipETRfjtsCW9qJK8Ipof67Pw9bodFWmkfkaUaRiIQ1dIV2VTlheshlBx3mpKr0KeK8VqnMMCtgN9rQtw=="],
|
422 |
|
423 |
+
"@types/cookie": ["@types/[email protected]", "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
424 |
+
|
425 |
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
426 |
|
427 |
"@types/estree": ["@types/[email protected]", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
|
|
|
570 |
|
571 |
"convert-source-map": ["[email protected]", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
572 |
|
573 |
+
"cookie": ["[email protected]", "https://registry.npmmirror.com/cookie/-/cookie-1.0.2.tgz", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
574 |
+
|
575 |
"cosmiconfig": ["[email protected]", "", { "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=="],
|
576 |
|
577 |
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
|
|
1108 |
|
1109 |
"react-dropzone": ["[email protected]", "", { "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=="],
|
1110 |
|
1111 |
+
"react-error-boundary": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ=="],
|
1112 |
+
|
1113 |
"react-i18next": ["[email protected]", "", { "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=="],
|
1114 |
|
1115 |
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
|
1122 |
|
1123 |
"react-remove-scroll-bar": ["[email protected]", "", { "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=="],
|
1124 |
|
1125 |
+
"react-router": ["[email protected]", "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=="],
|
1126 |
+
|
1127 |
+
"react-router-dom": ["[email protected]", "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=="],
|
1128 |
+
|
1129 |
"react-select": ["[email protected]", "", { "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=="],
|
1130 |
|
1131 |
"react-style-singleton": ["[email protected]", "", { "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=="],
|
|
|
1176 |
|
1177 |
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
1178 |
|
1179 |
+
"set-cookie-parser": ["[email protected]", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
1180 |
+
|
1181 |
"set-function-length": ["[email protected]", "", { "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=="],
|
1182 |
|
1183 |
"set-function-name": ["[email protected]", "", { "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=="],
|
|
|
1248 |
|
1249 |
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
1250 |
|
1251 |
+
"turbo-stream": ["[email protected]", "https://registry.npmmirror.com/turbo-stream/-/turbo-stream-2.4.0.tgz", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="],
|
1252 |
+
|
1253 |
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
1254 |
|
1255 |
"typed-array-buffer": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
lightrag_webui/env.development.smaple
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
# Development environment configuration
|
2 |
+
VITE_BACKEND_URL=/api
|
lightrag_webui/env.local.sample
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
VITE_BACKEND_URL=http://localhost:9621
|
2 |
+
VITE_API_PROXY=true
|
3 |
+
VITE_API_ENDPOINTS=/,/api,/documents,/graphs,/graph,/health,/query,/docs,/openapi.json,/login,/auth-status
|
lightrag_webui/index.html
CHANGED
@@ -5,7 +5,7 @@
|
|
5 |
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
6 |
<meta http-equiv="Pragma" content="no-cache" />
|
7 |
<meta http-equiv="Expires" content="0" />
|
8 |
-
<link rel="icon" type="image/svg+xml" href="
|
9 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
10 |
<title>Lightrag</title>
|
11 |
</head>
|
|
|
5 |
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
6 |
<meta http-equiv="Pragma" content="no-cache" />
|
7 |
<meta http-equiv="Expires" content="0" />
|
8 |
+
<link rel="icon" type="image/svg+xml" href="logo.png" />
|
9 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
10 |
<title>Lightrag</title>
|
11 |
</head>
|
lightrag_webui/package.json
CHANGED
@@ -49,9 +49,11 @@
|
|
49 |
"react": "^19.0.0",
|
50 |
"react-dom": "^19.0.0",
|
51 |
"react-dropzone": "^14.3.6",
|
|
|
52 |
"react-i18next": "^15.4.1",
|
53 |
"react-markdown": "^9.1.0",
|
54 |
"react-number-format": "^5.4.3",
|
|
|
55 |
"react-syntax-highlighter": "^15.6.1",
|
56 |
"rehype-react": "^8.0.0",
|
57 |
"remark-gfm": "^4.0.1",
|
|
|
49 |
"react": "^19.0.0",
|
50 |
"react-dom": "^19.0.0",
|
51 |
"react-dropzone": "^14.3.6",
|
52 |
+
"react-error-boundary": "^5.0.0",
|
53 |
"react-i18next": "^15.4.1",
|
54 |
"react-markdown": "^9.1.0",
|
55 |
"react-number-format": "^5.4.3",
|
56 |
+
"react-router-dom": "^7.3.0",
|
57 |
"react-syntax-highlighter": "^15.6.1",
|
58 |
"rehype-react": "^8.0.0",
|
59 |
"remark-gfm": "^4.0.1",
|
lightrag_webui/src/App.tsx
CHANGED
@@ -8,7 +8,6 @@ import { healthCheckInterval } from '@/lib/constants'
|
|
8 |
import { useBackendState } from '@/stores/state'
|
9 |
import { useSettingsStore } from '@/stores/settings'
|
10 |
import { useEffect } from 'react'
|
11 |
-
import { Toaster } from 'sonner'
|
12 |
import SiteHeader from '@/features/SiteHeader'
|
13 |
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
|
14 |
|
@@ -27,8 +26,6 @@ function App() {
|
|
27 |
|
28 |
// Health check
|
29 |
useEffect(() => {
|
30 |
-
if (!enableHealthCheck) return
|
31 |
-
|
32 |
// Check immediately
|
33 |
useBackendState.getState().check()
|
34 |
|
@@ -56,24 +53,24 @@ function App() {
|
|
56 |
return (
|
57 |
<ThemeProvider>
|
58 |
<TabVisibilityProvider>
|
59 |
-
<main className="flex h-screen w-screen overflow-
|
60 |
<Tabs
|
61 |
defaultValue={currentTab}
|
62 |
-
className="!m-0 flex grow flex-col !p-0"
|
63 |
onValueChange={handleTabChange}
|
64 |
>
|
65 |
<SiteHeader />
|
66 |
<div className="relative grow">
|
67 |
-
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0">
|
68 |
<DocumentManager />
|
69 |
</TabsContent>
|
70 |
-
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0">
|
71 |
<GraphViewer />
|
72 |
</TabsContent>
|
73 |
-
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0">
|
74 |
<RetrievalTesting />
|
75 |
</TabsContent>
|
76 |
-
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0">
|
77 |
<ApiSite />
|
78 |
</TabsContent>
|
79 |
</div>
|
@@ -81,7 +78,6 @@ function App() {
|
|
81 |
{enableHealthCheck && <StatusIndicator />}
|
82 |
{message !== null && !apiKeyInvalid && <MessageAlert />}
|
83 |
{apiKeyInvalid && <ApiKeyAlert />}
|
84 |
-
<Toaster />
|
85 |
</main>
|
86 |
</TabVisibilityProvider>
|
87 |
</ThemeProvider>
|
|
|
8 |
import { useBackendState } from '@/stores/state'
|
9 |
import { useSettingsStore } from '@/stores/settings'
|
10 |
import { useEffect } from 'react'
|
|
|
11 |
import SiteHeader from '@/features/SiteHeader'
|
12 |
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
|
13 |
|
|
|
26 |
|
27 |
// Health check
|
28 |
useEffect(() => {
|
|
|
|
|
29 |
// Check immediately
|
30 |
useBackendState.getState().check()
|
31 |
|
|
|
53 |
return (
|
54 |
<ThemeProvider>
|
55 |
<TabVisibilityProvider>
|
56 |
+
<main className="flex h-screen w-screen overflow-hidden">
|
57 |
<Tabs
|
58 |
defaultValue={currentTab}
|
59 |
+
className="!m-0 flex grow flex-col !p-0 overflow-hidden"
|
60 |
onValueChange={handleTabChange}
|
61 |
>
|
62 |
<SiteHeader />
|
63 |
<div className="relative grow">
|
64 |
+
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0 overflow-auto">
|
65 |
<DocumentManager />
|
66 |
</TabsContent>
|
67 |
+
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
68 |
<GraphViewer />
|
69 |
</TabsContent>
|
70 |
+
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
71 |
<RetrievalTesting />
|
72 |
</TabsContent>
|
73 |
+
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
74 |
<ApiSite />
|
75 |
</TabsContent>
|
76 |
</div>
|
|
|
78 |
{enableHealthCheck && <StatusIndicator />}
|
79 |
{message !== null && !apiKeyInvalid && <MessageAlert />}
|
80 |
{apiKeyInvalid && <ApiKeyAlert />}
|
|
|
81 |
</main>
|
82 |
</TabVisibilityProvider>
|
83 |
</ThemeProvider>
|
lightrag_webui/src/AppRouter.tsx
ADDED
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
|
2 |
+
import { useEffect, useState } from 'react'
|
3 |
+
import { useAuthStore } from '@/stores/state'
|
4 |
+
import { navigationService } from '@/services/navigation'
|
5 |
+
import { getAuthStatus } from '@/api/lightrag'
|
6 |
+
import { toast } from 'sonner'
|
7 |
+
import { Toaster } from 'sonner'
|
8 |
+
import App from './App'
|
9 |
+
import LoginPage from '@/features/LoginPage'
|
10 |
+
import ThemeProvider from '@/components/ThemeProvider'
|
11 |
+
|
12 |
+
interface ProtectedRouteProps {
|
13 |
+
children: React.ReactNode
|
14 |
+
}
|
15 |
+
|
16 |
+
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
17 |
+
const { isAuthenticated } = useAuthStore()
|
18 |
+
const [isChecking, setIsChecking] = useState(true)
|
19 |
+
const navigate = useNavigate()
|
20 |
+
|
21 |
+
// Set navigate function for navigation service
|
22 |
+
useEffect(() => {
|
23 |
+
navigationService.setNavigate(navigate)
|
24 |
+
}, [navigate])
|
25 |
+
|
26 |
+
useEffect(() => {
|
27 |
+
let isMounted = true; // Flag to prevent state updates after unmount
|
28 |
+
|
29 |
+
// This effect will run when the component mounts
|
30 |
+
// and will check if authentication is required
|
31 |
+
const checkAuthStatus = async () => {
|
32 |
+
try {
|
33 |
+
// Skip check if already authenticated
|
34 |
+
if (isAuthenticated) {
|
35 |
+
if (isMounted) setIsChecking(false);
|
36 |
+
return;
|
37 |
+
}
|
38 |
+
|
39 |
+
const status = await getAuthStatus()
|
40 |
+
|
41 |
+
// Only proceed if component is still mounted
|
42 |
+
if (!isMounted) return;
|
43 |
+
|
44 |
+
if (!status.auth_configured && status.access_token) {
|
45 |
+
// If auth is not configured, use the guest token
|
46 |
+
useAuthStore.getState().login(status.access_token, true)
|
47 |
+
if (status.message) {
|
48 |
+
toast.info(status.message)
|
49 |
+
}
|
50 |
+
}
|
51 |
+
} catch (error) {
|
52 |
+
console.error('Failed to check auth status:', error)
|
53 |
+
} finally {
|
54 |
+
// Only update state if component is still mounted
|
55 |
+
if (isMounted) {
|
56 |
+
setIsChecking(false)
|
57 |
+
}
|
58 |
+
}
|
59 |
+
}
|
60 |
+
|
61 |
+
// Execute immediately
|
62 |
+
checkAuthStatus()
|
63 |
+
|
64 |
+
// Cleanup function to prevent state updates after unmount
|
65 |
+
return () => {
|
66 |
+
isMounted = false;
|
67 |
+
}
|
68 |
+
}, [isAuthenticated])
|
69 |
+
|
70 |
+
// Handle navigation when authentication status changes
|
71 |
+
useEffect(() => {
|
72 |
+
if (!isChecking && !isAuthenticated) {
|
73 |
+
const currentPath = window.location.hash.slice(1); // Remove the '#' from hash
|
74 |
+
const isLoginPage = currentPath === '/login';
|
75 |
+
|
76 |
+
if (!isLoginPage) {
|
77 |
+
// Use navigation service for redirection
|
78 |
+
console.log('Not authenticated, redirecting to login');
|
79 |
+
navigationService.navigateToLogin();
|
80 |
+
}
|
81 |
+
}
|
82 |
+
}, [isChecking, isAuthenticated]);
|
83 |
+
|
84 |
+
// Show nothing while checking auth status or when not authenticated on login page
|
85 |
+
if (isChecking || (!isAuthenticated && window.location.hash.slice(1) === '/login')) {
|
86 |
+
return null;
|
87 |
+
}
|
88 |
+
|
89 |
+
// Show children only when authenticated
|
90 |
+
if (!isAuthenticated) {
|
91 |
+
return null;
|
92 |
+
}
|
93 |
+
|
94 |
+
return <>{children}</>;
|
95 |
+
}
|
96 |
+
|
97 |
+
const AppContent = () => {
|
98 |
+
const [initializing, setInitializing] = useState(true)
|
99 |
+
const { isAuthenticated } = useAuthStore()
|
100 |
+
const navigate = useNavigate()
|
101 |
+
|
102 |
+
// Set navigate function for navigation service
|
103 |
+
useEffect(() => {
|
104 |
+
navigationService.setNavigate(navigate)
|
105 |
+
}, [navigate])
|
106 |
+
|
107 |
+
// Check token validity and auth configuration on app initialization
|
108 |
+
useEffect(() => {
|
109 |
+
let isMounted = true; // Flag to prevent state updates after unmount
|
110 |
+
|
111 |
+
const checkAuth = async () => {
|
112 |
+
try {
|
113 |
+
const token = localStorage.getItem('LIGHTRAG-API-TOKEN')
|
114 |
+
|
115 |
+
// If we have a token, we're already authenticated
|
116 |
+
if (token && isAuthenticated) {
|
117 |
+
if (isMounted) setInitializing(false);
|
118 |
+
return;
|
119 |
+
}
|
120 |
+
|
121 |
+
// If no token or not authenticated, check if auth is configured
|
122 |
+
const status = await getAuthStatus()
|
123 |
+
|
124 |
+
// Only proceed if component is still mounted
|
125 |
+
if (!isMounted) return;
|
126 |
+
|
127 |
+
if (!status.auth_configured && status.access_token) {
|
128 |
+
// If auth is not configured, use the guest token
|
129 |
+
useAuthStore.getState().login(status.access_token, true)
|
130 |
+
if (status.message) {
|
131 |
+
toast.info(status.message)
|
132 |
+
}
|
133 |
+
} else if (!token) {
|
134 |
+
// Only logout if we don't have a token
|
135 |
+
useAuthStore.getState().logout()
|
136 |
+
}
|
137 |
+
} catch (error) {
|
138 |
+
console.error('Auth initialization error:', error)
|
139 |
+
if (isMounted && !isAuthenticated) {
|
140 |
+
useAuthStore.getState().logout()
|
141 |
+
}
|
142 |
+
} finally {
|
143 |
+
// Only update state if component is still mounted
|
144 |
+
if (isMounted) {
|
145 |
+
setInitializing(false)
|
146 |
+
}
|
147 |
+
}
|
148 |
+
}
|
149 |
+
|
150 |
+
// Execute immediately
|
151 |
+
checkAuth()
|
152 |
+
|
153 |
+
// Cleanup function to prevent state updates after unmount
|
154 |
+
return () => {
|
155 |
+
isMounted = false;
|
156 |
+
}
|
157 |
+
}, [isAuthenticated])
|
158 |
+
|
159 |
+
// Show nothing while initializing
|
160 |
+
if (initializing) {
|
161 |
+
return null
|
162 |
+
}
|
163 |
+
|
164 |
+
return (
|
165 |
+
<Routes>
|
166 |
+
<Route path="/login" element={<LoginPage />} />
|
167 |
+
<Route
|
168 |
+
path="/*"
|
169 |
+
element={
|
170 |
+
<ProtectedRoute>
|
171 |
+
<App />
|
172 |
+
</ProtectedRoute>
|
173 |
+
}
|
174 |
+
/>
|
175 |
+
</Routes>
|
176 |
+
)
|
177 |
+
}
|
178 |
+
|
179 |
+
const AppRouter = () => {
|
180 |
+
return (
|
181 |
+
<ThemeProvider>
|
182 |
+
<Router>
|
183 |
+
<AppContent />
|
184 |
+
<Toaster position="bottom-center" />
|
185 |
+
</Router>
|
186 |
+
</ThemeProvider>
|
187 |
+
)
|
188 |
+
}
|
189 |
+
|
190 |
+
export default AppRouter
|
lightrag_webui/src/api/lightrag.ts
CHANGED
@@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios'
|
|
2 |
import { backendBaseUrl } from '@/lib/constants'
|
3 |
import { errorMessage } from '@/lib/utils'
|
4 |
import { useSettingsStore } from '@/stores/settings'
|
|
|
5 |
|
6 |
// Types
|
7 |
export type LightragNodeType = {
|
@@ -125,6 +126,21 @@ export type DocsStatusesResponse = {
|
|
125 |
statuses: Record<DocStatus, DocStatusResponse[]>
|
126 |
}
|
127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
128 |
export const InvalidApiKeyError = 'Invalid API Key'
|
129 |
export const RequireApiKeError = 'API Key required'
|
130 |
|
@@ -136,9 +152,15 @@ const axiosInstance = axios.create({
|
|
136 |
}
|
137 |
})
|
138 |
|
139 |
-
// Interceptor
|
140 |
axiosInstance.interceptors.request.use((config) => {
|
141 |
const apiKey = useSettingsStore.getState().apiKey
|
|
|
|
|
|
|
|
|
|
|
|
|
142 |
if (apiKey) {
|
143 |
config.headers['X-API-Key'] = apiKey
|
144 |
}
|
@@ -150,6 +172,16 @@ axiosInstance.interceptors.response.use(
|
|
150 |
(response) => response,
|
151 |
(error: AxiosError) => {
|
152 |
if (error.response) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
153 |
throw new Error(
|
154 |
`${error.response.status} ${error.response.statusText}\n${JSON.stringify(
|
155 |
error.response.data
|
@@ -324,3 +356,74 @@ export const clearDocuments = async (): Promise<DocActionResponse> => {
|
|
324 |
const response = await axiosInstance.delete('/documents')
|
325 |
return response.data
|
326 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
import { backendBaseUrl } from '@/lib/constants'
|
3 |
import { errorMessage } from '@/lib/utils'
|
4 |
import { useSettingsStore } from '@/stores/settings'
|
5 |
+
import { navigationService } from '@/services/navigation'
|
6 |
|
7 |
// Types
|
8 |
export type LightragNodeType = {
|
|
|
126 |
statuses: Record<DocStatus, DocStatusResponse[]>
|
127 |
}
|
128 |
|
129 |
+
export type AuthStatusResponse = {
|
130 |
+
auth_configured: boolean
|
131 |
+
access_token?: string
|
132 |
+
token_type?: string
|
133 |
+
auth_mode?: 'enabled' | 'disabled'
|
134 |
+
message?: string
|
135 |
+
}
|
136 |
+
|
137 |
+
export type LoginResponse = {
|
138 |
+
access_token: string
|
139 |
+
token_type: string
|
140 |
+
auth_mode?: 'enabled' | 'disabled' // Authentication mode identifier
|
141 |
+
message?: string // Optional message
|
142 |
+
}
|
143 |
+
|
144 |
export const InvalidApiKeyError = 'Invalid API Key'
|
145 |
export const RequireApiKeError = 'API Key required'
|
146 |
|
|
|
152 |
}
|
153 |
})
|
154 |
|
155 |
+
// Interceptor: add api key and check authentication
|
156 |
axiosInstance.interceptors.request.use((config) => {
|
157 |
const apiKey = useSettingsStore.getState().apiKey
|
158 |
+
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
|
159 |
+
|
160 |
+
// Always include token if it exists, regardless of path
|
161 |
+
if (token) {
|
162 |
+
config.headers['Authorization'] = `Bearer ${token}`
|
163 |
+
}
|
164 |
if (apiKey) {
|
165 |
config.headers['X-API-Key'] = apiKey
|
166 |
}
|
|
|
172 |
(response) => response,
|
173 |
(error: AxiosError) => {
|
174 |
if (error.response) {
|
175 |
+
if (error.response?.status === 401) {
|
176 |
+
// For login API, throw error directly
|
177 |
+
if (error.config?.url?.includes('/login')) {
|
178 |
+
throw error;
|
179 |
+
}
|
180 |
+
// For other APIs, navigate to login page
|
181 |
+
navigationService.navigateToLogin();
|
182 |
+
// Return a never-resolving promise to prevent further execution
|
183 |
+
return new Promise(() => {});
|
184 |
+
}
|
185 |
throw new Error(
|
186 |
`${error.response.status} ${error.response.statusText}\n${JSON.stringify(
|
187 |
error.response.data
|
|
|
356 |
const response = await axiosInstance.delete('/documents')
|
357 |
return response.data
|
358 |
}
|
359 |
+
|
360 |
+
export const getAuthStatus = async (): Promise<AuthStatusResponse> => {
|
361 |
+
try {
|
362 |
+
// Add a timeout to the request to prevent hanging
|
363 |
+
const response = await axiosInstance.get('/auth-status', {
|
364 |
+
timeout: 5000, // 5 second timeout
|
365 |
+
headers: {
|
366 |
+
'Accept': 'application/json' // Explicitly request JSON
|
367 |
+
}
|
368 |
+
});
|
369 |
+
|
370 |
+
// Check if response is HTML (which indicates a redirect or wrong endpoint)
|
371 |
+
const contentType = response.headers['content-type'] || '';
|
372 |
+
if (contentType.includes('text/html')) {
|
373 |
+
console.warn('Received HTML response instead of JSON for auth-status endpoint');
|
374 |
+
return {
|
375 |
+
auth_configured: true,
|
376 |
+
auth_mode: 'enabled'
|
377 |
+
};
|
378 |
+
}
|
379 |
+
|
380 |
+
// Strict validation of the response data
|
381 |
+
if (response.data &&
|
382 |
+
typeof response.data === 'object' &&
|
383 |
+
'auth_configured' in response.data &&
|
384 |
+
typeof response.data.auth_configured === 'boolean') {
|
385 |
+
|
386 |
+
// For unconfigured auth, ensure we have an access token
|
387 |
+
if (!response.data.auth_configured) {
|
388 |
+
if (response.data.access_token && typeof response.data.access_token === 'string') {
|
389 |
+
return response.data;
|
390 |
+
} else {
|
391 |
+
console.warn('Auth not configured but no valid access token provided');
|
392 |
+
}
|
393 |
+
} else {
|
394 |
+
// For configured auth, just return the data
|
395 |
+
return response.data;
|
396 |
+
}
|
397 |
+
}
|
398 |
+
|
399 |
+
// If response data is invalid but we got a response, log it
|
400 |
+
console.warn('Received invalid auth status response:', response.data);
|
401 |
+
|
402 |
+
// Default to auth configured if response is invalid
|
403 |
+
return {
|
404 |
+
auth_configured: true,
|
405 |
+
auth_mode: 'enabled'
|
406 |
+
};
|
407 |
+
} catch (error) {
|
408 |
+
// If the request fails, assume authentication is configured
|
409 |
+
console.error('Failed to get auth status:', errorMessage(error));
|
410 |
+
return {
|
411 |
+
auth_configured: true,
|
412 |
+
auth_mode: 'enabled'
|
413 |
+
};
|
414 |
+
}
|
415 |
+
}
|
416 |
+
|
417 |
+
export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
|
418 |
+
const formData = new FormData();
|
419 |
+
formData.append('username', username);
|
420 |
+
formData.append('password', password);
|
421 |
+
|
422 |
+
const response = await axiosInstance.post('/login', formData, {
|
423 |
+
headers: {
|
424 |
+
'Content-Type': 'multipart/form-data'
|
425 |
+
}
|
426 |
+
});
|
427 |
+
|
428 |
+
return response.data;
|
429 |
+
}
|
lightrag_webui/src/components/AppSettings.tsx
CHANGED
@@ -5,8 +5,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|
5 |
import { useSettingsStore } from '@/stores/settings'
|
6 |
import { PaletteIcon } from 'lucide-react'
|
7 |
import { useTranslation } from 'react-i18next'
|
|
|
8 |
|
9 |
-
|
|
|
|
|
|
|
|
|
10 |
const [opened, setOpened] = useState<boolean>(false)
|
11 |
const { t } = useTranslation()
|
12 |
|
@@ -27,7 +32,7 @@ export default function AppSettings() {
|
|
27 |
return (
|
28 |
<Popover open={opened} onOpenChange={setOpened}>
|
29 |
<PopoverTrigger asChild>
|
30 |
-
<Button variant="
|
31 |
<PaletteIcon className="h-5 w-5" />
|
32 |
</Button>
|
33 |
</PopoverTrigger>
|
|
|
5 |
import { useSettingsStore } from '@/stores/settings'
|
6 |
import { PaletteIcon } from 'lucide-react'
|
7 |
import { useTranslation } from 'react-i18next'
|
8 |
+
import { cn } from '@/lib/utils'
|
9 |
|
10 |
+
interface AppSettingsProps {
|
11 |
+
className?: string
|
12 |
+
}
|
13 |
+
|
14 |
+
export default function AppSettings({ className }: AppSettingsProps) {
|
15 |
const [opened, setOpened] = useState<boolean>(false)
|
16 |
const { t } = useTranslation()
|
17 |
|
|
|
32 |
return (
|
33 |
<Popover open={opened} onOpenChange={setOpened}>
|
34 |
<PopoverTrigger asChild>
|
35 |
+
<Button variant="ghost" size="icon" className={cn('h-9 w-9', className)}>
|
36 |
<PaletteIcon className="h-5 w-5" />
|
37 |
</Button>
|
38 |
</PopoverTrigger>
|
lightrag_webui/src/components/LanguageToggle.tsx
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import Button from '@/components/ui/Button'
|
2 |
+
import { useCallback } from 'react'
|
3 |
+
import { controlButtonVariant } from '@/lib/constants'
|
4 |
+
import { useTranslation } from 'react-i18next'
|
5 |
+
import { useSettingsStore } from '@/stores/settings'
|
6 |
+
|
7 |
+
/**
|
8 |
+
* Component that toggles the language between English and Chinese.
|
9 |
+
*/
|
10 |
+
export default function LanguageToggle() {
|
11 |
+
const { i18n } = useTranslation()
|
12 |
+
const currentLanguage = i18n.language
|
13 |
+
const setLanguage = useSettingsStore.use.setLanguage()
|
14 |
+
|
15 |
+
const setEnglish = useCallback(() => {
|
16 |
+
i18n.changeLanguage('en')
|
17 |
+
setLanguage('en')
|
18 |
+
}, [i18n, setLanguage])
|
19 |
+
|
20 |
+
const setChinese = useCallback(() => {
|
21 |
+
i18n.changeLanguage('zh')
|
22 |
+
setLanguage('zh')
|
23 |
+
}, [i18n, setLanguage])
|
24 |
+
|
25 |
+
if (currentLanguage === 'zh') {
|
26 |
+
return (
|
27 |
+
<Button
|
28 |
+
onClick={setEnglish}
|
29 |
+
variant={controlButtonVariant}
|
30 |
+
tooltip="Switch to English"
|
31 |
+
size="icon"
|
32 |
+
side="bottom"
|
33 |
+
>
|
34 |
+
中
|
35 |
+
</Button>
|
36 |
+
)
|
37 |
+
}
|
38 |
+
return (
|
39 |
+
<Button
|
40 |
+
onClick={setChinese}
|
41 |
+
variant={controlButtonVariant}
|
42 |
+
tooltip="切换到中文"
|
43 |
+
size="icon"
|
44 |
+
side="bottom"
|
45 |
+
>
|
46 |
+
EN
|
47 |
+
</Button>
|
48 |
+
)
|
49 |
+
}
|
lightrag_webui/src/components/graph/FocusOnNode.tsx
CHANGED
@@ -13,23 +13,37 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
|
|
13 |
* When the selected item changes, highlighted the node and center the camera on it.
|
14 |
*/
|
15 |
useEffect(() => {
|
|
|
|
|
16 |
if (move) {
|
17 |
-
if (node) {
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
20 |
} else {
|
21 |
// If no node is selected but move is true, reset to default view
|
22 |
-
sigma.setCustomBBox(null)
|
23 |
-
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
}
|
25 |
-
useGraphStore.getState().setMoveToSelectedNode(false)
|
26 |
-
} else if (node) {
|
27 |
-
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
28 |
}
|
29 |
|
30 |
return () => {
|
31 |
-
if (node) {
|
32 |
-
|
|
|
|
|
|
|
|
|
33 |
}
|
34 |
}
|
35 |
}, [node, move, sigma, gotoNode])
|
|
|
13 |
* When the selected item changes, highlighted the node and center the camera on it.
|
14 |
*/
|
15 |
useEffect(() => {
|
16 |
+
const graph = sigma.getGraph();
|
17 |
+
|
18 |
if (move) {
|
19 |
+
if (node && graph.hasNode(node)) {
|
20 |
+
try {
|
21 |
+
graph.setNodeAttribute(node, 'highlighted', true);
|
22 |
+
gotoNode(node);
|
23 |
+
} catch (error) {
|
24 |
+
console.error('Error focusing on node:', error);
|
25 |
+
}
|
26 |
} else {
|
27 |
// If no node is selected but move is true, reset to default view
|
28 |
+
sigma.setCustomBBox(null);
|
29 |
+
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 });
|
30 |
+
}
|
31 |
+
useGraphStore.getState().setMoveToSelectedNode(false);
|
32 |
+
} else if (node && graph.hasNode(node)) {
|
33 |
+
try {
|
34 |
+
graph.setNodeAttribute(node, 'highlighted', true);
|
35 |
+
} catch (error) {
|
36 |
+
console.error('Error highlighting node:', error);
|
37 |
}
|
|
|
|
|
|
|
38 |
}
|
39 |
|
40 |
return () => {
|
41 |
+
if (node && graph.hasNode(node)) {
|
42 |
+
try {
|
43 |
+
graph.setNodeAttribute(node, 'highlighted', false);
|
44 |
+
} catch (error) {
|
45 |
+
console.error('Error cleaning up node highlight:', error);
|
46 |
+
}
|
47 |
}
|
48 |
}
|
49 |
}, [node, move, sigma, gotoNode])
|
lightrag_webui/src/components/graph/GraphControl.tsx
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
-
import {
|
2 |
-
import
|
3 |
// import { useLayoutCircular } from '@react-sigma/layout-circular'
|
4 |
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
5 |
import { useEffect } from 'react'
|
@@ -25,7 +25,6 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
|
25 |
const sigma = useSigma<NodeType, EdgeType>()
|
26 |
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
27 |
const setSettings = useSetSettings<NodeType, EdgeType>()
|
28 |
-
const loadGraph = useLoadGraph<NodeType, EdgeType>()
|
29 |
|
30 |
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
31 |
const { assign: assignLayout } = useLayoutForceAtlas2({
|
@@ -45,14 +44,42 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
|
45 |
|
46 |
/**
|
47 |
* When component mount or maxIterations changes
|
48 |
-
* =>
|
49 |
*/
|
50 |
useEffect(() => {
|
51 |
-
if (sigmaGraph) {
|
52 |
-
|
53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
}
|
55 |
-
}, [
|
56 |
|
57 |
/**
|
58 |
* When component mount
|
@@ -138,14 +165,18 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
|
138 |
const _focusedNode = focusedNode || selectedNode
|
139 |
const _focusedEdge = focusedEdge || selectedEdge
|
140 |
|
141 |
-
if (_focusedNode) {
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
|
|
|
|
146 |
}
|
|
|
|
|
147 |
}
|
148 |
-
} else if (_focusedEdge) {
|
149 |
if (graph.extremities(_focusedEdge).includes(node)) {
|
150 |
newData.highlighted = true
|
151 |
newData.size = 3
|
@@ -173,21 +204,28 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
|
|
173 |
if (!disableHoverEffect) {
|
174 |
const _focusedNode = focusedNode || selectedNode
|
175 |
|
176 |
-
if (_focusedNode) {
|
177 |
-
|
178 |
-
if (
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
|
|
|
|
184 |
}
|
|
|
|
|
185 |
}
|
186 |
} else {
|
187 |
-
|
188 |
-
|
|
|
|
|
|
|
189 |
newData.color = Constants.edgeColorSelected
|
190 |
-
} else if (edge ===
|
191 |
newData.color = Constants.edgeColorHighlighted
|
192 |
} else if (hideUnselectedEdges) {
|
193 |
newData.hidden = true
|
|
|
1 |
+
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
2 |
+
import { AbstractGraph } from 'graphology-types'
|
3 |
// import { useLayoutCircular } from '@react-sigma/layout-circular'
|
4 |
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
5 |
import { useEffect } from 'react'
|
|
|
25 |
const sigma = useSigma<NodeType, EdgeType>()
|
26 |
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
27 |
const setSettings = useSetSettings<NodeType, EdgeType>()
|
|
|
28 |
|
29 |
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
30 |
const { assign: assignLayout } = useLayoutForceAtlas2({
|
|
|
44 |
|
45 |
/**
|
46 |
* When component mount or maxIterations changes
|
47 |
+
* => ensure graph reference and apply layout
|
48 |
*/
|
49 |
useEffect(() => {
|
50 |
+
if (sigmaGraph && sigma) {
|
51 |
+
// Ensure sigma binding to sigmaGraph
|
52 |
+
try {
|
53 |
+
if (typeof sigma.setGraph === 'function') {
|
54 |
+
sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>);
|
55 |
+
console.log('Binding graph to sigma instance');
|
56 |
+
} else {
|
57 |
+
(sigma as any).graph = sigmaGraph;
|
58 |
+
console.warn('Simgma missing setGraph function, set graph property directly');
|
59 |
+
}
|
60 |
+
} catch (error) {
|
61 |
+
console.error('Error setting graph on sigma instance:', error);
|
62 |
+
}
|
63 |
+
|
64 |
+
assignLayout();
|
65 |
+
console.log('Initial layout applied to graph');
|
66 |
+
}
|
67 |
+
}, [sigma, sigmaGraph, assignLayout, maxIterations])
|
68 |
+
|
69 |
+
/**
|
70 |
+
* Ensure the sigma instance is set in the store
|
71 |
+
* This provides a backup in case the instance wasn't set in GraphViewer
|
72 |
+
*/
|
73 |
+
useEffect(() => {
|
74 |
+
if (sigma) {
|
75 |
+
// Double-check that the store has the sigma instance
|
76 |
+
const currentInstance = useGraphStore.getState().sigmaInstance;
|
77 |
+
if (!currentInstance) {
|
78 |
+
console.log('Setting sigma instance from GraphControl');
|
79 |
+
useGraphStore.getState().setSigmaInstance(sigma);
|
80 |
+
}
|
81 |
}
|
82 |
+
}, [sigma]);
|
83 |
|
84 |
/**
|
85 |
* When component mount
|
|
|
165 |
const _focusedNode = focusedNode || selectedNode
|
166 |
const _focusedEdge = focusedEdge || selectedEdge
|
167 |
|
168 |
+
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
169 |
+
try {
|
170 |
+
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
171 |
+
newData.highlighted = true
|
172 |
+
if (node === selectedNode) {
|
173 |
+
newData.borderColor = Constants.nodeBorderColorSelected
|
174 |
+
}
|
175 |
}
|
176 |
+
} catch (error) {
|
177 |
+
console.error('Error in nodeReducer:', error);
|
178 |
}
|
179 |
+
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
|
180 |
if (graph.extremities(_focusedEdge).includes(node)) {
|
181 |
newData.highlighted = true
|
182 |
newData.size = 3
|
|
|
204 |
if (!disableHoverEffect) {
|
205 |
const _focusedNode = focusedNode || selectedNode
|
206 |
|
207 |
+
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
208 |
+
try {
|
209 |
+
if (hideUnselectedEdges) {
|
210 |
+
if (!graph.extremities(edge).includes(_focusedNode)) {
|
211 |
+
newData.hidden = true
|
212 |
+
}
|
213 |
+
} else {
|
214 |
+
if (graph.extremities(edge).includes(_focusedNode)) {
|
215 |
+
newData.color = Constants.edgeColorHighlighted
|
216 |
+
}
|
217 |
}
|
218 |
+
} catch (error) {
|
219 |
+
console.error('Error in edgeReducer:', error);
|
220 |
}
|
221 |
} else {
|
222 |
+
const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;
|
223 |
+
const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;
|
224 |
+
|
225 |
+
if (_selectedEdge || _focusedEdge) {
|
226 |
+
if (edge === _selectedEdge) {
|
227 |
newData.color = Constants.edgeColorSelected
|
228 |
+
} else if (edge === _focusedEdge) {
|
229 |
newData.color = Constants.edgeColorHighlighted
|
230 |
} else if (hideUnselectedEdges) {
|
231 |
newData.hidden = true
|
lightrag_webui/src/components/graph/GraphLabels.tsx
CHANGED
@@ -2,20 +2,23 @@ import { useCallback, useEffect, useRef } from 'react'
|
|
2 |
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
3 |
import { useSettingsStore } from '@/stores/settings'
|
4 |
import { useGraphStore } from '@/stores/graph'
|
5 |
-
import { labelListLimit } from '@/lib/constants'
|
6 |
import MiniSearch from 'minisearch'
|
7 |
import { useTranslation } from 'react-i18next'
|
|
|
|
|
8 |
|
9 |
const GraphLabels = () => {
|
10 |
const { t } = useTranslation()
|
11 |
const label = useSettingsStore.use.queryLabel()
|
12 |
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
|
|
|
13 |
const labelsLoadedRef = useRef(false)
|
14 |
|
15 |
// Track if a fetch is in progress to prevent multiple simultaneous fetches
|
16 |
const fetchInProgressRef = useRef(false)
|
17 |
|
18 |
-
// Fetch labels
|
19 |
useEffect(() => {
|
20 |
// Check if we've already attempted to fetch labels in this session
|
21 |
const labelsFetchAttempted = useGraphStore.getState().labelsFetchAttempted
|
@@ -26,8 +29,6 @@ const GraphLabels = () => {
|
|
26 |
// Set global flag to indicate we've attempted to fetch in this session
|
27 |
useGraphStore.getState().setLabelsFetchAttempted(true)
|
28 |
|
29 |
-
console.log('Fetching graph labels (once per session)...')
|
30 |
-
|
31 |
useGraphStore.getState().fetchAllDatabaseLabels()
|
32 |
.then(() => {
|
33 |
labelsLoadedRef.current = true
|
@@ -42,6 +43,14 @@ const GraphLabels = () => {
|
|
42 |
}
|
43 |
}, []) // Empty dependency array ensures this only runs once on mount
|
44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
const getSearchEngine = useCallback(() => {
|
46 |
// Create search engine
|
47 |
const searchEngine = new MiniSearch({
|
@@ -83,52 +92,73 @@ const GraphLabels = () => {
|
|
83 |
[getSearchEngine]
|
84 |
)
|
85 |
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
triggerClassName="max-h-8"
|
90 |
-
searchInputClassName="max-h-8"
|
91 |
-
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
92 |
-
fetcher={fetchData}
|
93 |
-
renderOption={(item) => <div>{item}</div>}
|
94 |
-
getOptionValue={(item) => item}
|
95 |
-
getDisplayValue={(item) => <div>{item}</div>}
|
96 |
-
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
97 |
-
label={t('graphPanel.graphLabels.label')}
|
98 |
-
placeholder={t('graphPanel.graphLabels.placeholder')}
|
99 |
-
value={label !== null ? label : '*'}
|
100 |
-
onChange={(newLabel) => {
|
101 |
-
const currentLabel = useSettingsStore.getState().queryLabel
|
102 |
|
103 |
-
|
104 |
-
|
105 |
-
newLabel = '*'
|
106 |
-
}
|
107 |
|
108 |
-
|
109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
110 |
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
115 |
|
116 |
-
//
|
117 |
-
if (
|
118 |
-
|
119 |
-
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
|
120 |
}
|
121 |
-
}
|
122 |
|
123 |
-
|
124 |
-
// reselect the same itme means qery all
|
125 |
-
useSettingsStore.getState().setQueryLabel('*')
|
126 |
-
} else {
|
127 |
useSettingsStore.getState().setQueryLabel(newLabel)
|
128 |
-
}
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
)
|
133 |
}
|
134 |
|
|
|
2 |
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
3 |
import { useSettingsStore } from '@/stores/settings'
|
4 |
import { useGraphStore } from '@/stores/graph'
|
5 |
+
import { labelListLimit, controlButtonVariant } from '@/lib/constants'
|
6 |
import MiniSearch from 'minisearch'
|
7 |
import { useTranslation } from 'react-i18next'
|
8 |
+
import { RefreshCw } from 'lucide-react'
|
9 |
+
import Button from '@/components/ui/Button'
|
10 |
|
11 |
const GraphLabels = () => {
|
12 |
const { t } = useTranslation()
|
13 |
const label = useSettingsStore.use.queryLabel()
|
14 |
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
|
15 |
+
const rawGraph = useGraphStore.use.rawGraph()
|
16 |
const labelsLoadedRef = useRef(false)
|
17 |
|
18 |
// Track if a fetch is in progress to prevent multiple simultaneous fetches
|
19 |
const fetchInProgressRef = useRef(false)
|
20 |
|
21 |
+
// Fetch labels and trigger initial data load
|
22 |
useEffect(() => {
|
23 |
// Check if we've already attempted to fetch labels in this session
|
24 |
const labelsFetchAttempted = useGraphStore.getState().labelsFetchAttempted
|
|
|
29 |
// Set global flag to indicate we've attempted to fetch in this session
|
30 |
useGraphStore.getState().setLabelsFetchAttempted(true)
|
31 |
|
|
|
|
|
32 |
useGraphStore.getState().fetchAllDatabaseLabels()
|
33 |
.then(() => {
|
34 |
labelsLoadedRef.current = true
|
|
|
43 |
}
|
44 |
}, []) // Empty dependency array ensures this only runs once on mount
|
45 |
|
46 |
+
// Trigger data load when labels are loaded
|
47 |
+
useEffect(() => {
|
48 |
+
if (labelsLoadedRef.current) {
|
49 |
+
// Reset the fetch attempted flag to force a new data fetch
|
50 |
+
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
51 |
+
}
|
52 |
+
}, [label])
|
53 |
+
|
54 |
const getSearchEngine = useCallback(() => {
|
55 |
// Create search engine
|
56 |
const searchEngine = new MiniSearch({
|
|
|
92 |
[getSearchEngine]
|
93 |
)
|
94 |
|
95 |
+
const handleRefresh = useCallback(() => {
|
96 |
+
// Reset labels fetch status to allow fetching labels again
|
97 |
+
useGraphStore.getState().setLabelsFetchAttempted(false)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
98 |
|
99 |
+
// Reset graph data fetch status directly, not depending on allDatabaseLabels changes
|
100 |
+
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
|
|
|
|
101 |
|
102 |
+
// Fetch all labels again
|
103 |
+
useGraphStore.getState().fetchAllDatabaseLabels()
|
104 |
+
.then(() => {
|
105 |
+
// Trigger a graph data reload by changing the query label back and forth
|
106 |
+
const currentLabel = useSettingsStore.getState().queryLabel
|
107 |
+
useSettingsStore.getState().setQueryLabel('')
|
108 |
+
setTimeout(() => {
|
109 |
+
useSettingsStore.getState().setQueryLabel(currentLabel)
|
110 |
+
}, 0)
|
111 |
+
})
|
112 |
+
.catch((error) => {
|
113 |
+
console.error('Failed to refresh labels:', error)
|
114 |
+
})
|
115 |
+
}, [])
|
116 |
|
117 |
+
return (
|
118 |
+
<div className="flex items-center">
|
119 |
+
{rawGraph && (
|
120 |
+
<Button
|
121 |
+
size="icon"
|
122 |
+
variant={controlButtonVariant}
|
123 |
+
onClick={handleRefresh}
|
124 |
+
tooltip={t('graphPanel.graphLabels.refreshTooltip')}
|
125 |
+
className="mr-1"
|
126 |
+
>
|
127 |
+
<RefreshCw className="h-4 w-4" />
|
128 |
+
</Button>
|
129 |
+
)}
|
130 |
+
<AsyncSelect<string>
|
131 |
+
className="ml-2"
|
132 |
+
triggerClassName="max-h-8"
|
133 |
+
searchInputClassName="max-h-8"
|
134 |
+
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
135 |
+
fetcher={fetchData}
|
136 |
+
renderOption={(item) => <div>{item}</div>}
|
137 |
+
getOptionValue={(item) => item}
|
138 |
+
getDisplayValue={(item) => <div>{item}</div>}
|
139 |
+
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
140 |
+
label={t('graphPanel.graphLabels.label')}
|
141 |
+
placeholder={t('graphPanel.graphLabels.placeholder')}
|
142 |
+
value={label !== null ? label : '*'}
|
143 |
+
onChange={(newLabel) => {
|
144 |
+
const currentLabel = useSettingsStore.getState().queryLabel
|
145 |
+
|
146 |
+
// select the last item means query all
|
147 |
+
if (newLabel === '...') {
|
148 |
+
newLabel = '*'
|
149 |
+
}
|
150 |
|
151 |
+
// Handle reselecting the same label
|
152 |
+
if (newLabel === currentLabel && newLabel !== '*') {
|
153 |
+
newLabel = '*'
|
|
|
154 |
}
|
|
|
155 |
|
156 |
+
// Update the label, which will trigger the useEffect to handle data loading
|
|
|
|
|
|
|
157 |
useSettingsStore.getState().setQueryLabel(newLabel)
|
158 |
+
}}
|
159 |
+
clearable={false} // Prevent clearing value on reselect
|
160 |
+
/>
|
161 |
+
</div>
|
162 |
)
|
163 |
}
|
164 |
|
lightrag_webui/src/components/graph/GraphSearch.tsx
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import { FC, useCallback, useEffect
|
2 |
import {
|
3 |
EdgeById,
|
4 |
NodeById,
|
@@ -11,28 +11,34 @@ import { useGraphStore } from '@/stores/graph'
|
|
11 |
import MiniSearch from 'minisearch'
|
12 |
import { useTranslation } from 'react-i18next'
|
13 |
|
14 |
-
|
|
|
|
|
|
|
|
|
15 |
id: string
|
16 |
type: 'nodes' | 'edges' | 'message'
|
17 |
message?: string
|
18 |
}
|
19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
function OptionComponent(item: OptionItem) {
|
21 |
return (
|
22 |
<div>
|
23 |
-
{item.type === 'nodes' && <
|
24 |
{item.type === 'edges' && <EdgeById id={item.id} />}
|
25 |
{item.type === 'message' && <div>{item.message}</div>}
|
26 |
</div>
|
27 |
)
|
28 |
}
|
29 |
|
30 |
-
const messageId = '__message_item'
|
31 |
-
// Reset this cache when graph changes to ensure fresh search results
|
32 |
-
const lastGraph: any = {
|
33 |
-
graph: null,
|
34 |
-
searchEngine: null
|
35 |
-
}
|
36 |
|
37 |
/**
|
38 |
* Component thats display the search input.
|
@@ -48,25 +54,24 @@ export const GraphSearchInput = ({
|
|
48 |
}) => {
|
49 |
const { t } = useTranslation()
|
50 |
const graph = useGraphStore.use.sigmaGraph()
|
|
|
51 |
|
52 |
-
//
|
53 |
useEffect(() => {
|
54 |
if (graph) {
|
55 |
-
|
56 |
-
lastGraph.graph = null;
|
57 |
-
lastGraph.searchEngine = null;
|
58 |
}
|
59 |
}, [graph]);
|
60 |
|
61 |
-
|
62 |
-
|
63 |
-
|
|
|
|
|
64 |
}
|
65 |
-
if (!graph || graph.nodes().length == 0) return
|
66 |
-
|
67 |
-
lastGraph.graph = graph
|
68 |
|
69 |
-
|
|
|
70 |
idField: 'id',
|
71 |
fields: ['label'],
|
72 |
searchOptions: {
|
@@ -78,16 +83,16 @@ export const GraphSearchInput = ({
|
|
78 |
}
|
79 |
})
|
80 |
|
81 |
-
// Add
|
82 |
const documents = graph.nodes().map((id: string) => ({
|
83 |
id: id,
|
84 |
label: graph.getNodeAttribute(id, 'label')
|
85 |
}))
|
86 |
-
|
87 |
|
88 |
-
|
89 |
-
|
90 |
-
}, [graph])
|
91 |
|
92 |
/**
|
93 |
* Loading the options while the user is typing.
|
@@ -95,22 +100,35 @@ export const GraphSearchInput = ({
|
|
95 |
const loadOptions = useCallback(
|
96 |
async (query?: string): Promise<OptionItem[]> => {
|
97 |
if (onFocus) onFocus(null)
|
98 |
-
if (!graph || !searchEngine) return []
|
99 |
|
100 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
101 |
if (!query) {
|
102 |
-
const nodeIds = graph.nodes()
|
|
|
|
|
103 |
return nodeIds.map(id => ({
|
104 |
id,
|
105 |
type: 'nodes'
|
106 |
}))
|
107 |
}
|
108 |
|
109 |
-
// If has query, search nodes
|
110 |
-
const result: OptionItem[] = searchEngine.search(query)
|
111 |
-
id: r.id
|
112 |
-
|
113 |
-
|
|
|
|
|
114 |
|
115 |
// prettier-ignore
|
116 |
return result.length <= searchResultLimit
|
|
|
1 |
+
import { FC, useCallback, useEffect } from 'react'
|
2 |
import {
|
3 |
EdgeById,
|
4 |
NodeById,
|
|
|
11 |
import MiniSearch from 'minisearch'
|
12 |
import { useTranslation } from 'react-i18next'
|
13 |
|
14 |
+
// Message item identifier for search results
|
15 |
+
export const messageId = '__message_item'
|
16 |
+
|
17 |
+
// Search result option item interface
|
18 |
+
export interface OptionItem {
|
19 |
id: string
|
20 |
type: 'nodes' | 'edges' | 'message'
|
21 |
message?: string
|
22 |
}
|
23 |
|
24 |
+
const NodeOption = ({ id }: { id: string }) => {
|
25 |
+
const graph = useGraphStore.use.sigmaGraph()
|
26 |
+
if (!graph?.hasNode(id)) {
|
27 |
+
return null
|
28 |
+
}
|
29 |
+
return <NodeById id={id} />
|
30 |
+
}
|
31 |
+
|
32 |
function OptionComponent(item: OptionItem) {
|
33 |
return (
|
34 |
<div>
|
35 |
+
{item.type === 'nodes' && <NodeOption id={item.id} />}
|
36 |
{item.type === 'edges' && <EdgeById id={item.id} />}
|
37 |
{item.type === 'message' && <div>{item.message}</div>}
|
38 |
</div>
|
39 |
)
|
40 |
}
|
41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
|
43 |
/**
|
44 |
* Component thats display the search input.
|
|
|
54 |
}) => {
|
55 |
const { t } = useTranslation()
|
56 |
const graph = useGraphStore.use.sigmaGraph()
|
57 |
+
const searchEngine = useGraphStore.use.searchEngine()
|
58 |
|
59 |
+
// Reset search engine when graph changes
|
60 |
useEffect(() => {
|
61 |
if (graph) {
|
62 |
+
useGraphStore.getState().resetSearchEngine()
|
|
|
|
|
63 |
}
|
64 |
}, [graph]);
|
65 |
|
66 |
+
// Create search engine when needed
|
67 |
+
useEffect(() => {
|
68 |
+
// Skip if no graph, empty graph, or search engine already exists
|
69 |
+
if (!graph || graph.nodes().length === 0 || searchEngine) {
|
70 |
+
return
|
71 |
}
|
|
|
|
|
|
|
72 |
|
73 |
+
// Create new search engine
|
74 |
+
const newSearchEngine = new MiniSearch({
|
75 |
idField: 'id',
|
76 |
fields: ['label'],
|
77 |
searchOptions: {
|
|
|
83 |
}
|
84 |
})
|
85 |
|
86 |
+
// Add nodes to search engine
|
87 |
const documents = graph.nodes().map((id: string) => ({
|
88 |
id: id,
|
89 |
label: graph.getNodeAttribute(id, 'label')
|
90 |
}))
|
91 |
+
newSearchEngine.addAll(documents)
|
92 |
|
93 |
+
// Update search engine in store
|
94 |
+
useGraphStore.getState().setSearchEngine(newSearchEngine)
|
95 |
+
}, [graph, searchEngine])
|
96 |
|
97 |
/**
|
98 |
* Loading the options while the user is typing.
|
|
|
100 |
const loadOptions = useCallback(
|
101 |
async (query?: string): Promise<OptionItem[]> => {
|
102 |
if (onFocus) onFocus(null)
|
|
|
103 |
|
104 |
+
// Safety checks to prevent crashes
|
105 |
+
if (!graph || !searchEngine) {
|
106 |
+
return []
|
107 |
+
}
|
108 |
+
|
109 |
+
// Verify graph has nodes before proceeding
|
110 |
+
if (graph.nodes().length === 0) {
|
111 |
+
return []
|
112 |
+
}
|
113 |
+
|
114 |
+
// If no query, return some nodes for user to select
|
115 |
if (!query) {
|
116 |
+
const nodeIds = graph.nodes()
|
117 |
+
.filter(id => graph.hasNode(id))
|
118 |
+
.slice(0, searchResultLimit)
|
119 |
return nodeIds.map(id => ({
|
120 |
id,
|
121 |
type: 'nodes'
|
122 |
}))
|
123 |
}
|
124 |
|
125 |
+
// If has query, search nodes and verify they still exist
|
126 |
+
const result: OptionItem[] = searchEngine.search(query)
|
127 |
+
.filter((r: { id: string }) => graph.hasNode(r.id))
|
128 |
+
.map((r: { id: string }) => ({
|
129 |
+
id: r.id,
|
130 |
+
type: 'nodes'
|
131 |
+
}))
|
132 |
|
133 |
// prettier-ignore
|
134 |
return result.length <= searchResultLimit
|
lightrag_webui/src/components/graph/LayoutsControl.tsx
CHANGED
@@ -7,7 +7,7 @@ import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
|
|
7 |
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
8 |
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
|
9 |
import { useLayoutRandom } from '@react-sigma/layout-random'
|
10 |
-
import { useCallback, useMemo, useState, useEffect } from 'react'
|
11 |
|
12 |
import Button from '@/components/ui/Button'
|
13 |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
@@ -26,43 +26,161 @@ type LayoutName =
|
|
26 |
| 'Force Directed'
|
27 |
| 'Force Atlas'
|
28 |
|
29 |
-
|
|
|
|
|
|
|
|
|
|
|
30 |
const sigma = useSigma()
|
31 |
-
|
|
|
|
|
|
|
32 |
const { t } = useTranslation()
|
33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
/**
|
35 |
* Init component when Sigma or component settings change.
|
36 |
*/
|
37 |
useEffect(() => {
|
38 |
if (!sigma) {
|
|
|
39 |
return
|
40 |
}
|
41 |
|
42 |
-
//
|
43 |
let timeout: number | null = null
|
44 |
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
}
|
52 |
|
53 |
-
//
|
54 |
return () => {
|
55 |
-
|
|
|
|
|
|
|
|
|
56 |
if (timeout) {
|
57 |
-
clearTimeout(timeout)
|
58 |
}
|
|
|
59 |
}
|
60 |
-
}, [autoRunFor,
|
61 |
|
62 |
return (
|
63 |
<Button
|
64 |
size="icon"
|
65 |
-
onClick={
|
66 |
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
|
67 |
variant={controlButtonVariant}
|
68 |
>
|
@@ -85,8 +203,27 @@ const LayoutsControl = () => {
|
|
85 |
const layoutCircular = useLayoutCircular()
|
86 |
const layoutCirclepack = useLayoutCirclepack()
|
87 |
const layoutRandom = useLayoutRandom()
|
88 |
-
const layoutNoverlap = useLayoutNoverlap({
|
89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
90 |
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
|
91 |
const workerNoverlap = useWorkerLayoutNoverlap()
|
92 |
const workerForce = useWorkerLayoutForce()
|
@@ -130,10 +267,23 @@ const LayoutsControl = () => {
|
|
130 |
|
131 |
const runLayout = useCallback(
|
132 |
(newLayout: LayoutName) => {
|
133 |
-
console.debug(newLayout)
|
134 |
const { positions } = layouts[newLayout].layout
|
135 |
-
|
136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
137 |
},
|
138 |
[layouts, sigma]
|
139 |
)
|
@@ -142,7 +292,10 @@ const LayoutsControl = () => {
|
|
142 |
<>
|
143 |
<div>
|
144 |
{layouts[layout] && 'worker' in layouts[layout] && (
|
145 |
-
<WorkerLayoutControl
|
|
|
|
|
|
|
146 |
)}
|
147 |
</div>
|
148 |
<div>
|
|
|
7 |
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
8 |
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
|
9 |
import { useLayoutRandom } from '@react-sigma/layout-random'
|
10 |
+
import { useCallback, useMemo, useState, useEffect, useRef } from 'react'
|
11 |
|
12 |
import Button from '@/components/ui/Button'
|
13 |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
|
|
26 |
| 'Force Directed'
|
27 |
| 'Force Atlas'
|
28 |
|
29 |
+
// Extend WorkerLayoutControlProps to include mainLayout
|
30 |
+
interface ExtendedWorkerLayoutControlProps extends WorkerLayoutControlProps {
|
31 |
+
mainLayout: LayoutHook;
|
32 |
+
}
|
33 |
+
|
34 |
+
const WorkerLayoutControl = ({ layout, autoRunFor, mainLayout }: ExtendedWorkerLayoutControlProps) => {
|
35 |
const sigma = useSigma()
|
36 |
+
// Use local state to track animation running status
|
37 |
+
const [isRunning, setIsRunning] = useState(false)
|
38 |
+
// Timer reference for animation
|
39 |
+
const animationTimerRef = useRef<number | null>(null)
|
40 |
const { t } = useTranslation()
|
41 |
|
42 |
+
// Function to update node positions using the layout algorithm
|
43 |
+
const updatePositions = useCallback(() => {
|
44 |
+
if (!sigma) return
|
45 |
+
|
46 |
+
try {
|
47 |
+
const graph = sigma.getGraph()
|
48 |
+
if (!graph || graph.order === 0) return
|
49 |
+
|
50 |
+
// Use mainLayout to get positions, similar to refreshLayout function
|
51 |
+
// console.log('Getting positions from mainLayout')
|
52 |
+
const positions = mainLayout.positions()
|
53 |
+
|
54 |
+
// Animate nodes to new positions
|
55 |
+
// console.log('Updating node positions with layout algorithm')
|
56 |
+
animateNodes(graph, positions, { duration: 300 }) // Reduced duration for more frequent updates
|
57 |
+
} catch (error) {
|
58 |
+
console.error('Error updating positions:', error)
|
59 |
+
// Stop animation if there's an error
|
60 |
+
if (animationTimerRef.current) {
|
61 |
+
window.clearInterval(animationTimerRef.current)
|
62 |
+
animationTimerRef.current = null
|
63 |
+
setIsRunning(false)
|
64 |
+
}
|
65 |
+
}
|
66 |
+
}, [sigma, mainLayout])
|
67 |
+
|
68 |
+
// Improved click handler that uses our own animation timer
|
69 |
+
const handleClick = useCallback(() => {
|
70 |
+
if (isRunning) {
|
71 |
+
// Stop the animation
|
72 |
+
console.log('Stopping layout animation')
|
73 |
+
if (animationTimerRef.current) {
|
74 |
+
window.clearInterval(animationTimerRef.current)
|
75 |
+
animationTimerRef.current = null
|
76 |
+
}
|
77 |
+
|
78 |
+
// Try to kill the layout algorithm if it's running
|
79 |
+
try {
|
80 |
+
if (typeof layout.kill === 'function') {
|
81 |
+
layout.kill()
|
82 |
+
console.log('Layout algorithm killed')
|
83 |
+
} else if (typeof layout.stop === 'function') {
|
84 |
+
layout.stop()
|
85 |
+
console.log('Layout algorithm stopped')
|
86 |
+
}
|
87 |
+
} catch (error) {
|
88 |
+
console.error('Error stopping layout algorithm:', error)
|
89 |
+
}
|
90 |
+
|
91 |
+
setIsRunning(false)
|
92 |
+
} else {
|
93 |
+
// Start the animation
|
94 |
+
console.log('Starting layout animation')
|
95 |
+
|
96 |
+
// Initial position update
|
97 |
+
updatePositions()
|
98 |
+
|
99 |
+
// Set up interval for continuous updates
|
100 |
+
animationTimerRef.current = window.setInterval(() => {
|
101 |
+
updatePositions()
|
102 |
+
}, 200) // Reduced interval to create overlapping animations for smoother transitions
|
103 |
+
|
104 |
+
setIsRunning(true)
|
105 |
+
|
106 |
+
// Set a timeout to automatically stop the animation after 3 seconds
|
107 |
+
setTimeout(() => {
|
108 |
+
if (animationTimerRef.current) {
|
109 |
+
console.log('Auto-stopping layout animation after 3 seconds')
|
110 |
+
window.clearInterval(animationTimerRef.current)
|
111 |
+
animationTimerRef.current = null
|
112 |
+
setIsRunning(false)
|
113 |
+
|
114 |
+
// Try to stop the layout algorithm
|
115 |
+
try {
|
116 |
+
if (typeof layout.kill === 'function') {
|
117 |
+
layout.kill()
|
118 |
+
} else if (typeof layout.stop === 'function') {
|
119 |
+
layout.stop()
|
120 |
+
}
|
121 |
+
} catch (error) {
|
122 |
+
console.error('Error stopping layout algorithm:', error)
|
123 |
+
}
|
124 |
+
}
|
125 |
+
}, 3000)
|
126 |
+
}
|
127 |
+
}, [isRunning, layout, updatePositions])
|
128 |
+
|
129 |
/**
|
130 |
* Init component when Sigma or component settings change.
|
131 |
*/
|
132 |
useEffect(() => {
|
133 |
if (!sigma) {
|
134 |
+
console.log('No sigma instance available')
|
135 |
return
|
136 |
}
|
137 |
|
138 |
+
// Auto-run if specified
|
139 |
let timeout: number | null = null
|
140 |
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
|
141 |
+
console.log('Auto-starting layout animation')
|
142 |
+
|
143 |
+
// Initial position update
|
144 |
+
updatePositions()
|
145 |
+
|
146 |
+
// Set up interval for continuous updates
|
147 |
+
animationTimerRef.current = window.setInterval(() => {
|
148 |
+
updatePositions()
|
149 |
+
}, 200) // Reduced interval to create overlapping animations for smoother transitions
|
150 |
+
|
151 |
+
setIsRunning(true)
|
152 |
+
|
153 |
+
// Set a timeout to stop it if autoRunFor > 0
|
154 |
+
if (autoRunFor > 0) {
|
155 |
+
timeout = window.setTimeout(() => {
|
156 |
+
console.log('Auto-stopping layout animation after timeout')
|
157 |
+
if (animationTimerRef.current) {
|
158 |
+
window.clearInterval(animationTimerRef.current)
|
159 |
+
animationTimerRef.current = null
|
160 |
+
}
|
161 |
+
setIsRunning(false)
|
162 |
+
}, autoRunFor)
|
163 |
+
}
|
164 |
}
|
165 |
|
166 |
+
// Cleanup function
|
167 |
return () => {
|
168 |
+
// console.log('Cleaning up WorkerLayoutControl')
|
169 |
+
if (animationTimerRef.current) {
|
170 |
+
window.clearInterval(animationTimerRef.current)
|
171 |
+
animationTimerRef.current = null
|
172 |
+
}
|
173 |
if (timeout) {
|
174 |
+
window.clearTimeout(timeout)
|
175 |
}
|
176 |
+
setIsRunning(false)
|
177 |
}
|
178 |
+
}, [autoRunFor, sigma, updatePositions])
|
179 |
|
180 |
return (
|
181 |
<Button
|
182 |
size="icon"
|
183 |
+
onClick={handleClick}
|
184 |
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
|
185 |
variant={controlButtonVariant}
|
186 |
>
|
|
|
203 |
const layoutCircular = useLayoutCircular()
|
204 |
const layoutCirclepack = useLayoutCirclepack()
|
205 |
const layoutRandom = useLayoutRandom()
|
206 |
+
const layoutNoverlap = useLayoutNoverlap({
|
207 |
+
maxIterations: maxIterations,
|
208 |
+
settings: {
|
209 |
+
margin: 5,
|
210 |
+
expansion: 1.1,
|
211 |
+
gridSize: 1,
|
212 |
+
ratio: 1,
|
213 |
+
speed: 3,
|
214 |
+
}
|
215 |
+
})
|
216 |
+
// Add parameters for Force Directed layout to improve convergence
|
217 |
+
const layoutForce = useLayoutForce({
|
218 |
+
maxIterations: maxIterations,
|
219 |
+
settings: {
|
220 |
+
attraction: 0.0003, // Lower attraction force to reduce oscillation
|
221 |
+
repulsion: 0.05, // Lower repulsion force to reduce oscillation
|
222 |
+
gravity: 0.01, // Increase gravity to make nodes converge to center faster
|
223 |
+
inertia: 0.4, // Lower inertia to add damping effect
|
224 |
+
maxMove: 100 // Limit maximum movement per step to prevent large jumps
|
225 |
+
}
|
226 |
+
})
|
227 |
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
|
228 |
const workerNoverlap = useWorkerLayoutNoverlap()
|
229 |
const workerForce = useWorkerLayoutForce()
|
|
|
267 |
|
268 |
const runLayout = useCallback(
|
269 |
(newLayout: LayoutName) => {
|
270 |
+
console.debug('Running layout:', newLayout)
|
271 |
const { positions } = layouts[newLayout].layout
|
272 |
+
|
273 |
+
try {
|
274 |
+
const graph = sigma.getGraph()
|
275 |
+
if (!graph) {
|
276 |
+
console.error('No graph available')
|
277 |
+
return
|
278 |
+
}
|
279 |
+
|
280 |
+
const pos = positions()
|
281 |
+
console.log('Positions calculated, animating nodes')
|
282 |
+
animateNodes(graph, pos, { duration: 400 })
|
283 |
+
setLayout(newLayout)
|
284 |
+
} catch (error) {
|
285 |
+
console.error('Error running layout:', error)
|
286 |
+
}
|
287 |
},
|
288 |
[layouts, sigma]
|
289 |
)
|
|
|
292 |
<>
|
293 |
<div>
|
294 |
{layouts[layout] && 'worker' in layouts[layout] && (
|
295 |
+
<WorkerLayoutControl
|
296 |
+
layout={layouts[layout].worker!}
|
297 |
+
mainLayout={layouts[layout].layout}
|
298 |
+
/>
|
299 |
)}
|
300 |
</div>
|
301 |
<div>
|
lightrag_webui/src/components/graph/PropertiesView.tsx
CHANGED
@@ -1,8 +1,10 @@
|
|
1 |
import { useEffect, useState } from 'react'
|
2 |
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
|
3 |
import Text from '@/components/ui/Text'
|
|
|
4 |
import useLightragGraph from '@/hooks/useLightragGraph'
|
5 |
import { useTranslation } from 'react-i18next'
|
|
|
6 |
|
7 |
/**
|
8 |
* Component that view properties of elements in graph.
|
@@ -88,22 +90,41 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
|
|
88 |
const relationships = []
|
89 |
|
90 |
if (state.sigmaGraph && state.rawGraph) {
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
const neighbour = state.rawGraph.getNode(neighbourId)
|
97 |
-
if (neighbour) {
|
98 |
-
relationships.push({
|
99 |
-
type: 'Neighbour',
|
100 |
-
id: neighbourId,
|
101 |
-
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
102 |
-
})
|
103 |
}
|
104 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
105 |
}
|
106 |
}
|
|
|
107 |
return {
|
108 |
...node,
|
109 |
relationships
|
@@ -112,8 +133,31 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
|
|
112 |
|
113 |
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
|
114 |
const state = useGraphStore.getState()
|
115 |
-
|
116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
117 |
return {
|
118 |
...edge,
|
119 |
sourceNode,
|
@@ -157,9 +201,40 @@ const PropertyRow = ({
|
|
157 |
|
158 |
const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
159 |
const { t } = useTranslation()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
160 |
return (
|
161 |
<div className="flex flex-col gap-2">
|
162 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
163 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
164 |
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
|
165 |
<PropertyRow
|
@@ -171,7 +246,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
|
171 |
/>
|
172 |
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
|
173 |
</div>
|
174 |
-
<label className="text-md pl-1 font-bold tracking-wide text-
|
175 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
176 |
{Object.keys(node.properties)
|
177 |
.sort()
|
@@ -181,7 +256,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
|
181 |
</div>
|
182 |
{node.relationships.length > 0 && (
|
183 |
<>
|
184 |
-
<label className="text-md pl-1 font-bold tracking-wide text-
|
185 |
{t('graphPanel.propertiesView.node.relationships')}
|
186 |
</label>
|
187 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
@@ -208,7 +283,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
|
208 |
const { t } = useTranslation()
|
209 |
return (
|
210 |
<div className="flex flex-col gap-2">
|
211 |
-
<label className="text-md pl-1 font-bold tracking-wide text-
|
212 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
213 |
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
|
214 |
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
|
@@ -227,7 +302,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
|
227 |
}}
|
228 |
/>
|
229 |
</div>
|
230 |
-
<label className="text-md pl-1 font-bold tracking-wide text-
|
231 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
232 |
{Object.keys(edge.properties)
|
233 |
.sort()
|
|
|
1 |
import { useEffect, useState } from 'react'
|
2 |
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
|
3 |
import Text from '@/components/ui/Text'
|
4 |
+
import Button from '@/components/ui/Button'
|
5 |
import useLightragGraph from '@/hooks/useLightragGraph'
|
6 |
import { useTranslation } from 'react-i18next'
|
7 |
+
import { GitBranchPlus, Scissors } from 'lucide-react'
|
8 |
|
9 |
/**
|
10 |
* Component that view properties of elements in graph.
|
|
|
90 |
const relationships = []
|
91 |
|
92 |
if (state.sigmaGraph && state.rawGraph) {
|
93 |
+
try {
|
94 |
+
if (!state.sigmaGraph.hasNode(node.id)) {
|
95 |
+
return {
|
96 |
+
...node,
|
97 |
+
relationships: []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
98 |
}
|
99 |
}
|
100 |
+
|
101 |
+
const edges = state.sigmaGraph.edges(node.id)
|
102 |
+
|
103 |
+
for (const edgeId of edges) {
|
104 |
+
if (!state.sigmaGraph.hasEdge(edgeId)) continue;
|
105 |
+
|
106 |
+
const edge = state.rawGraph.getEdge(edgeId, true)
|
107 |
+
if (edge) {
|
108 |
+
const isTarget = node.id === edge.source
|
109 |
+
const neighbourId = isTarget ? edge.target : edge.source
|
110 |
+
|
111 |
+
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
|
112 |
+
|
113 |
+
const neighbour = state.rawGraph.getNode(neighbourId)
|
114 |
+
if (neighbour) {
|
115 |
+
relationships.push({
|
116 |
+
type: 'Neighbour',
|
117 |
+
id: neighbourId,
|
118 |
+
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
119 |
+
})
|
120 |
+
}
|
121 |
+
}
|
122 |
+
}
|
123 |
+
} catch (error) {
|
124 |
+
console.error('Error refining node properties:', error)
|
125 |
}
|
126 |
}
|
127 |
+
|
128 |
return {
|
129 |
...node,
|
130 |
relationships
|
|
|
133 |
|
134 |
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
|
135 |
const state = useGraphStore.getState()
|
136 |
+
let sourceNode: RawNodeType | undefined = undefined
|
137 |
+
let targetNode: RawNodeType | undefined = undefined
|
138 |
+
|
139 |
+
if (state.sigmaGraph && state.rawGraph) {
|
140 |
+
try {
|
141 |
+
if (!state.sigmaGraph.hasEdge(edge.id)) {
|
142 |
+
return {
|
143 |
+
...edge,
|
144 |
+
sourceNode: undefined,
|
145 |
+
targetNode: undefined
|
146 |
+
}
|
147 |
+
}
|
148 |
+
|
149 |
+
if (state.sigmaGraph.hasNode(edge.source)) {
|
150 |
+
sourceNode = state.rawGraph.getNode(edge.source)
|
151 |
+
}
|
152 |
+
|
153 |
+
if (state.sigmaGraph.hasNode(edge.target)) {
|
154 |
+
targetNode = state.rawGraph.getNode(edge.target)
|
155 |
+
}
|
156 |
+
} catch (error) {
|
157 |
+
console.error('Error refining edge properties:', error)
|
158 |
+
}
|
159 |
+
}
|
160 |
+
|
161 |
return {
|
162 |
...edge,
|
163 |
sourceNode,
|
|
|
201 |
|
202 |
const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
203 |
const { t } = useTranslation()
|
204 |
+
|
205 |
+
const handleExpandNode = () => {
|
206 |
+
useGraphStore.getState().triggerNodeExpand(node.id)
|
207 |
+
}
|
208 |
+
|
209 |
+
const handlePruneNode = () => {
|
210 |
+
useGraphStore.getState().triggerNodePrune(node.id)
|
211 |
+
}
|
212 |
+
|
213 |
return (
|
214 |
<div className="flex flex-col gap-2">
|
215 |
+
<div className="flex justify-between items-center">
|
216 |
+
<label className="text-md pl-1 font-bold tracking-wide text-blue-700">{t('graphPanel.propertiesView.node.title')}</label>
|
217 |
+
<div className="flex gap-3">
|
218 |
+
<Button
|
219 |
+
size="icon"
|
220 |
+
variant="ghost"
|
221 |
+
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
222 |
+
onClick={handleExpandNode}
|
223 |
+
tooltip={t('graphPanel.propertiesView.node.expandNode')}
|
224 |
+
>
|
225 |
+
<GitBranchPlus className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
226 |
+
</Button>
|
227 |
+
<Button
|
228 |
+
size="icon"
|
229 |
+
variant="ghost"
|
230 |
+
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
231 |
+
onClick={handlePruneNode}
|
232 |
+
tooltip={t('graphPanel.propertiesView.node.pruneNode')}
|
233 |
+
>
|
234 |
+
<Scissors className="h-4 w-4 text-gray-900 dark:text-gray-300" />
|
235 |
+
</Button>
|
236 |
+
</div>
|
237 |
+
</div>
|
238 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
239 |
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
|
240 |
<PropertyRow
|
|
|
246 |
/>
|
247 |
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
|
248 |
</div>
|
249 |
+
<label className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.node.properties')}</label>
|
250 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
251 |
{Object.keys(node.properties)
|
252 |
.sort()
|
|
|
256 |
</div>
|
257 |
{node.relationships.length > 0 && (
|
258 |
<>
|
259 |
+
<label className="text-md pl-1 font-bold tracking-wide text-emerald-700">
|
260 |
{t('graphPanel.propertiesView.node.relationships')}
|
261 |
</label>
|
262 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
|
|
283 |
const { t } = useTranslation()
|
284 |
return (
|
285 |
<div className="flex flex-col gap-2">
|
286 |
+
<label className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</label>
|
287 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
288 |
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
|
289 |
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
|
|
|
302 |
}}
|
303 |
/>
|
304 |
</div>
|
305 |
+
<label className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.edge.properties')}</label>
|
306 |
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
307 |
{Object.keys(edge.properties)
|
308 |
.sort()
|
lightrag_webui/src/components/graph/Settings.tsx
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import { useState, useCallback
|
2 |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
3 |
import Checkbox from '@/components/ui/Checkbox'
|
4 |
import Button from '@/components/ui/Button'
|
@@ -7,10 +7,8 @@ import Input from '@/components/ui/Input'
|
|
7 |
|
8 |
import { controlButtonVariant } from '@/lib/constants'
|
9 |
import { useSettingsStore } from '@/stores/settings'
|
10 |
-
import { useBackendState } from '@/stores/state'
|
11 |
-
import { useGraphStore } from '@/stores/graph'
|
12 |
|
13 |
-
import { SettingsIcon
|
14 |
import { useTranslation } from 'react-i18next';
|
15 |
|
16 |
/**
|
@@ -114,8 +112,6 @@ const LabeledNumberInput = ({
|
|
114 |
*/
|
115 |
export default function Settings() {
|
116 |
const [opened, setOpened] = useState<boolean>(false)
|
117 |
-
const [tempApiKey, setTempApiKey] = useState<string>('')
|
118 |
-
const refreshLayout = useGraphStore.use.refreshLayout()
|
119 |
|
120 |
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
121 |
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
@@ -129,11 +125,6 @@ export default function Settings() {
|
|
129 |
const graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
130 |
|
131 |
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
132 |
-
const apiKey = useSettingsStore.use.apiKey()
|
133 |
-
|
134 |
-
useEffect(() => {
|
135 |
-
setTempApiKey(apiKey || '')
|
136 |
-
}, [apiKey, opened])
|
137 |
|
138 |
const setEnableNodeDrag = useCallback(
|
139 |
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
@@ -182,11 +173,22 @@ export default function Settings() {
|
|
182 |
const setGraphQueryMaxDepth = useCallback((depth: number) => {
|
183 |
if (depth < 1) return
|
184 |
useSettingsStore.setState({ graphQueryMaxDepth: depth })
|
|
|
|
|
|
|
|
|
|
|
185 |
}, [])
|
186 |
|
187 |
const setGraphMinDegree = useCallback((degree: number) => {
|
188 |
if (degree < 0) return
|
189 |
useSettingsStore.setState({ graphMinDegree: degree })
|
|
|
|
|
|
|
|
|
|
|
|
|
190 |
}, [])
|
191 |
|
192 |
const setGraphLayoutMaxIterations = useCallback((iterations: number) => {
|
@@ -194,34 +196,19 @@ export default function Settings() {
|
|
194 |
useSettingsStore.setState({ graphLayoutMaxIterations: iterations })
|
195 |
}, [])
|
196 |
|
197 |
-
const setApiKey = useCallback(async () => {
|
198 |
-
useSettingsStore.setState({ apiKey: tempApiKey || null })
|
199 |
-
await useBackendState.getState().check()
|
200 |
-
setOpened(false)
|
201 |
-
}, [tempApiKey])
|
202 |
-
|
203 |
-
const handleTempApiKeyChange = useCallback(
|
204 |
-
(e: React.ChangeEvent<HTMLInputElement>) => {
|
205 |
-
setTempApiKey(e.target.value)
|
206 |
-
},
|
207 |
-
[setTempApiKey]
|
208 |
-
)
|
209 |
-
|
210 |
const { t } = useTranslation();
|
211 |
|
|
|
|
|
212 |
return (
|
213 |
<>
|
214 |
-
<Button
|
215 |
-
variant={controlButtonVariant}
|
216 |
-
tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
|
217 |
-
size="icon"
|
218 |
-
onClick={refreshLayout}
|
219 |
-
>
|
220 |
-
<RefreshCwIcon />
|
221 |
-
</Button>
|
222 |
<Popover open={opened} onOpenChange={setOpened}>
|
223 |
<PopoverTrigger asChild>
|
224 |
-
<Button
|
|
|
|
|
|
|
|
|
225 |
<SettingsIcon />
|
226 |
</Button>
|
227 |
</PopoverTrigger>
|
@@ -303,30 +290,15 @@ export default function Settings() {
|
|
303 |
onEditFinished={setGraphLayoutMaxIterations}
|
304 |
/>
|
305 |
<Separator />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
306 |
|
307 |
-
<div className="flex flex-col gap-2">
|
308 |
-
<label className="text-sm font-medium">{t('graphPanel.sideBar.settings.apiKey')}</label>
|
309 |
-
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
|
310 |
-
<div className="w-0 flex-1">
|
311 |
-
<Input
|
312 |
-
type="password"
|
313 |
-
value={tempApiKey}
|
314 |
-
onChange={handleTempApiKeyChange}
|
315 |
-
placeholder={t('graphPanel.sideBar.settings.enterYourAPIkey')}
|
316 |
-
className="max-h-full w-full min-w-0"
|
317 |
-
autoComplete="off"
|
318 |
-
/>
|
319 |
-
</div>
|
320 |
-
<Button
|
321 |
-
onClick={setApiKey}
|
322 |
-
variant="outline"
|
323 |
-
size="sm"
|
324 |
-
className="max-h-full shrink-0"
|
325 |
-
>
|
326 |
-
{t('graphPanel.sideBar.settings.save')}
|
327 |
-
</Button>
|
328 |
-
</form>
|
329 |
-
</div>
|
330 |
</div>
|
331 |
</PopoverContent>
|
332 |
</Popover>
|
|
|
1 |
+
import { useState, useCallback} from 'react'
|
2 |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
3 |
import Checkbox from '@/components/ui/Checkbox'
|
4 |
import Button from '@/components/ui/Button'
|
|
|
7 |
|
8 |
import { controlButtonVariant } from '@/lib/constants'
|
9 |
import { useSettingsStore } from '@/stores/settings'
|
|
|
|
|
10 |
|
11 |
+
import { SettingsIcon } from 'lucide-react'
|
12 |
import { useTranslation } from 'react-i18next';
|
13 |
|
14 |
/**
|
|
|
112 |
*/
|
113 |
export default function Settings() {
|
114 |
const [opened, setOpened] = useState<boolean>(false)
|
|
|
|
|
115 |
|
116 |
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
117 |
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
|
|
125 |
const graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()
|
126 |
|
127 |
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
|
|
|
|
|
|
|
|
|
|
128 |
|
129 |
const setEnableNodeDrag = useCallback(
|
130 |
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
|
|
173 |
const setGraphQueryMaxDepth = useCallback((depth: number) => {
|
174 |
if (depth < 1) return
|
175 |
useSettingsStore.setState({ graphQueryMaxDepth: depth })
|
176 |
+
const currentLabel = useSettingsStore.getState().queryLabel
|
177 |
+
useSettingsStore.getState().setQueryLabel('')
|
178 |
+
setTimeout(() => {
|
179 |
+
useSettingsStore.getState().setQueryLabel(currentLabel)
|
180 |
+
}, 300)
|
181 |
}, [])
|
182 |
|
183 |
const setGraphMinDegree = useCallback((degree: number) => {
|
184 |
if (degree < 0) return
|
185 |
useSettingsStore.setState({ graphMinDegree: degree })
|
186 |
+
const currentLabel = useSettingsStore.getState().queryLabel
|
187 |
+
useSettingsStore.getState().setQueryLabel('')
|
188 |
+
setTimeout(() => {
|
189 |
+
useSettingsStore.getState().setQueryLabel(currentLabel)
|
190 |
+
}, 300)
|
191 |
+
|
192 |
}, [])
|
193 |
|
194 |
const setGraphLayoutMaxIterations = useCallback((iterations: number) => {
|
|
|
196 |
useSettingsStore.setState({ graphLayoutMaxIterations: iterations })
|
197 |
}, [])
|
198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
199 |
const { t } = useTranslation();
|
200 |
|
201 |
+
const saveSettings = () => setOpened(false);
|
202 |
+
|
203 |
return (
|
204 |
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
205 |
<Popover open={opened} onOpenChange={setOpened}>
|
206 |
<PopoverTrigger asChild>
|
207 |
+
<Button
|
208 |
+
variant={controlButtonVariant}
|
209 |
+
tooltip={t('graphPanel.sideBar.settings.settings')}
|
210 |
+
size="icon"
|
211 |
+
>
|
212 |
<SettingsIcon />
|
213 |
</Button>
|
214 |
</PopoverTrigger>
|
|
|
290 |
onEditFinished={setGraphLayoutMaxIterations}
|
291 |
/>
|
292 |
<Separator />
|
293 |
+
<Button
|
294 |
+
onClick={saveSettings}
|
295 |
+
variant="outline"
|
296 |
+
size="sm"
|
297 |
+
className="ml-auto px-4"
|
298 |
+
>
|
299 |
+
{t('graphPanel.sideBar.settings.save')}
|
300 |
+
</Button>
|
301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
302 |
</div>
|
303 |
</PopoverContent>
|
304 |
</Popover>
|
lightrag_webui/src/components/graph/SettingsDisplay.tsx
CHANGED
@@ -11,7 +11,7 @@ const SettingsDisplay = () => {
|
|
11 |
const graphMinDegree = useSettingsStore.use.graphMinDegree()
|
12 |
|
13 |
return (
|
14 |
-
<div className="absolute bottom-
|
15 |
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
|
16 |
<div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
|
17 |
</div>
|
|
|
11 |
const graphMinDegree = useSettingsStore.use.graphMinDegree()
|
12 |
|
13 |
return (
|
14 |
+
<div className="absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
|
15 |
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
|
16 |
<div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
|
17 |
</div>
|
lightrag_webui/src/components/graph/ZoomControl.tsx
CHANGED
@@ -1,37 +1,107 @@
|
|
1 |
-
import { useCamera } from '@react-sigma/core'
|
2 |
import { useCallback } from 'react'
|
3 |
import Button from '@/components/ui/Button'
|
4 |
-
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
|
5 |
import { controlButtonVariant } from '@/lib/constants'
|
6 |
-
import { useTranslation } from
|
7 |
|
8 |
/**
|
9 |
* Component that provides zoom controls for the graph viewer.
|
10 |
*/
|
11 |
const ZoomControl = () => {
|
12 |
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
|
|
13 |
const { t } = useTranslation();
|
14 |
|
15 |
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
16 |
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
17 |
-
const handleResetZoom = useCallback(() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
|
19 |
return (
|
20 |
<>
|
21 |
-
<Button
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
23 |
</Button>
|
24 |
-
<Button
|
25 |
-
|
|
|
|
|
|
|
|
|
|
|
26 |
</Button>
|
27 |
<Button
|
28 |
variant={controlButtonVariant}
|
29 |
onClick={handleResetZoom}
|
30 |
-
tooltip={t(
|
31 |
size="icon"
|
32 |
>
|
33 |
<FullscreenIcon />
|
34 |
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
</>
|
36 |
)
|
37 |
}
|
|
|
1 |
+
import { useCamera, useSigma } from '@react-sigma/core'
|
2 |
import { useCallback } from 'react'
|
3 |
import Button from '@/components/ui/Button'
|
4 |
+
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon, RotateCwIcon, RotateCcwIcon } from 'lucide-react'
|
5 |
import { controlButtonVariant } from '@/lib/constants'
|
6 |
+
import { useTranslation } from 'react-i18next';
|
7 |
|
8 |
/**
|
9 |
* Component that provides zoom controls for the graph viewer.
|
10 |
*/
|
11 |
const ZoomControl = () => {
|
12 |
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
13 |
+
const sigma = useSigma()
|
14 |
const { t } = useTranslation();
|
15 |
|
16 |
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
17 |
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
18 |
+
const handleResetZoom = useCallback(() => {
|
19 |
+
if (!sigma) return
|
20 |
+
|
21 |
+
try {
|
22 |
+
// First clear any custom bounding box and refresh
|
23 |
+
sigma.setCustomBBox(null)
|
24 |
+
sigma.refresh()
|
25 |
+
|
26 |
+
// Get graph after refresh
|
27 |
+
const graph = sigma.getGraph()
|
28 |
+
|
29 |
+
// Check if graph has nodes before accessing them
|
30 |
+
if (!graph?.order || graph.nodes().length === 0) {
|
31 |
+
// Use reset() for empty graph case
|
32 |
+
reset()
|
33 |
+
return
|
34 |
+
}
|
35 |
+
|
36 |
+
sigma.getCamera().animate(
|
37 |
+
{ x: 0.5, y: 0.5, ratio: 1.1 },
|
38 |
+
{ duration: 1000 }
|
39 |
+
)
|
40 |
+
} catch (error) {
|
41 |
+
console.error('Error resetting zoom:', error)
|
42 |
+
// Use reset() as fallback on error
|
43 |
+
reset()
|
44 |
+
}
|
45 |
+
}, [sigma, reset])
|
46 |
+
|
47 |
+
const handleRotate = useCallback(() => {
|
48 |
+
if (!sigma) return
|
49 |
+
|
50 |
+
const camera = sigma.getCamera()
|
51 |
+
const currentAngle = camera.angle
|
52 |
+
const newAngle = currentAngle + Math.PI / 8
|
53 |
+
|
54 |
+
camera.animate(
|
55 |
+
{ angle: newAngle },
|
56 |
+
{ duration: 200 }
|
57 |
+
)
|
58 |
+
}, [sigma])
|
59 |
+
|
60 |
+
const handleRotateCounterClockwise = useCallback(() => {
|
61 |
+
if (!sigma) return
|
62 |
+
|
63 |
+
const camera = sigma.getCamera()
|
64 |
+
const currentAngle = camera.angle
|
65 |
+
const newAngle = currentAngle - Math.PI / 8
|
66 |
+
|
67 |
+
camera.animate(
|
68 |
+
{ angle: newAngle },
|
69 |
+
{ duration: 200 }
|
70 |
+
)
|
71 |
+
}, [sigma])
|
72 |
|
73 |
return (
|
74 |
<>
|
75 |
+
<Button
|
76 |
+
variant={controlButtonVariant}
|
77 |
+
onClick={handleRotateCounterClockwise}
|
78 |
+
tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}
|
79 |
+
size="icon"
|
80 |
+
>
|
81 |
+
<RotateCcwIcon />
|
82 |
</Button>
|
83 |
+
<Button
|
84 |
+
variant={controlButtonVariant}
|
85 |
+
onClick={handleRotate}
|
86 |
+
tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}
|
87 |
+
size="icon"
|
88 |
+
>
|
89 |
+
<RotateCwIcon />
|
90 |
</Button>
|
91 |
<Button
|
92 |
variant={controlButtonVariant}
|
93 |
onClick={handleResetZoom}
|
94 |
+
tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}
|
95 |
size="icon"
|
96 |
>
|
97 |
<FullscreenIcon />
|
98 |
</Button>
|
99 |
+
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t('graphPanel.sideBar.zoomControl.zoomIn')} size="icon">
|
100 |
+
<ZoomInIcon />
|
101 |
+
</Button>
|
102 |
+
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t('graphPanel.sideBar.zoomControl.zoomOut')} size="icon">
|
103 |
+
<ZoomOutIcon />
|
104 |
+
</Button>
|
105 |
</>
|
106 |
)
|
107 |
}
|
lightrag_webui/src/components/ui/Popover.tsx
CHANGED
@@ -11,18 +11,16 @@ const PopoverContent = React.forwardRef<
|
|
11 |
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
12 |
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
13 |
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
14 |
-
<PopoverPrimitive.
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
/>
|
25 |
-
</PopoverPrimitive.Portal>
|
26 |
))
|
27 |
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
28 |
|
|
|
11 |
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
12 |
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
13 |
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
14 |
+
<PopoverPrimitive.Content
|
15 |
+
ref={ref}
|
16 |
+
align={align}
|
17 |
+
sideOffset={sideOffset}
|
18 |
+
className={cn(
|
19 |
+
'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',
|
20 |
+
className
|
21 |
+
)}
|
22 |
+
{...props}
|
23 |
+
/>
|
|
|
|
|
24 |
))
|
25 |
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
26 |
|
lightrag_webui/src/components/ui/Tooltip.tsx
CHANGED
@@ -38,7 +38,7 @@ const TooltipContent = React.forwardRef<
|
|
38 |
side={side}
|
39 |
align={align}
|
40 |
className={cn(
|
41 |
-
'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',
|
42 |
className
|
43 |
)}
|
44 |
{...props}
|
|
|
38 |
side={side}
|
39 |
align={align}
|
40 |
className={cn(
|
41 |
+
'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',
|
42 |
className
|
43 |
)}
|
44 |
{...props}
|
lightrag_webui/src/contexts/TabVisibilityProvider.tsx
CHANGED
@@ -15,16 +15,22 @@ export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ ch
|
|
15 |
// Get current tab from settings store
|
16 |
const currentTab = useSettingsStore.use.currentTab();
|
17 |
|
18 |
-
// Initialize visibility state with
|
19 |
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
|
20 |
-
|
|
|
|
|
|
|
21 |
}));
|
22 |
|
23 |
-
//
|
24 |
useEffect(() => {
|
25 |
setVisibleTabs((prev) => ({
|
26 |
...prev,
|
27 |
-
|
|
|
|
|
|
|
28 |
}));
|
29 |
}, [currentTab]);
|
30 |
|
|
|
15 |
// Get current tab from settings store
|
16 |
const currentTab = useSettingsStore.use.currentTab();
|
17 |
|
18 |
+
// Initialize visibility state with all tabs visible
|
19 |
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
|
20 |
+
'documents': true,
|
21 |
+
'knowledge-graph': true,
|
22 |
+
'retrieval': true,
|
23 |
+
'api': true
|
24 |
}));
|
25 |
|
26 |
+
// Keep all tabs visible because we use CSS to control TAB visibility instead of React
|
27 |
useEffect(() => {
|
28 |
setVisibleTabs((prev) => ({
|
29 |
...prev,
|
30 |
+
'documents': true,
|
31 |
+
'knowledge-graph': true,
|
32 |
+
'retrieval': true,
|
33 |
+
'api': true
|
34 |
}));
|
35 |
}, [currentTab]);
|
36 |
|
lightrag_webui/src/features/DocumentManager.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import { useState, useEffect, useCallback
|
2 |
import { useTranslation } from 'react-i18next'
|
3 |
-
import {
|
4 |
import Button from '@/components/ui/Button'
|
5 |
import {
|
6 |
Table,
|
@@ -27,9 +27,7 @@ export default function DocumentManager() {
|
|
27 |
const { t } = useTranslation()
|
28 |
const health = useBackendState.use.health()
|
29 |
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
|
30 |
-
const
|
31 |
-
const isDocumentsTabVisible = isTabVisible('documents')
|
32 |
-
const initialLoadRef = useRef(false)
|
33 |
|
34 |
const fetchDocuments = useCallback(async () => {
|
35 |
try {
|
@@ -45,7 +43,6 @@ export default function DocumentManager() {
|
|
45 |
} else {
|
46 |
setDocs(null)
|
47 |
}
|
48 |
-
// console.log(docs)
|
49 |
} else {
|
50 |
setDocs(null)
|
51 |
}
|
@@ -54,13 +51,12 @@ export default function DocumentManager() {
|
|
54 |
}
|
55 |
}, [setDocs, t])
|
56 |
|
57 |
-
//
|
58 |
useEffect(() => {
|
59 |
-
if (
|
60 |
fetchDocuments()
|
61 |
-
initialLoadRef.current = true
|
62 |
}
|
63 |
-
}, [
|
64 |
|
65 |
const scanDocuments = useCallback(async () => {
|
66 |
try {
|
@@ -71,9 +67,9 @@ export default function DocumentManager() {
|
|
71 |
}
|
72 |
}, [t])
|
73 |
|
74 |
-
//
|
75 |
useEffect(() => {
|
76 |
-
if (
|
77 |
return
|
78 |
}
|
79 |
|
@@ -86,7 +82,7 @@ export default function DocumentManager() {
|
|
86 |
}, 5000)
|
87 |
|
88 |
return () => clearInterval(interval)
|
89 |
-
}, [health, fetchDocuments, t,
|
90 |
|
91 |
return (
|
92 |
<Card className="!size-full !rounded-none !border-none">
|
|
|
1 |
+
import { useState, useEffect, useCallback } from 'react'
|
2 |
import { useTranslation } from 'react-i18next'
|
3 |
+
import { useSettingsStore } from '@/stores/settings'
|
4 |
import Button from '@/components/ui/Button'
|
5 |
import {
|
6 |
Table,
|
|
|
27 |
const { t } = useTranslation()
|
28 |
const health = useBackendState.use.health()
|
29 |
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
|
30 |
+
const currentTab = useSettingsStore.use.currentTab()
|
|
|
|
|
31 |
|
32 |
const fetchDocuments = useCallback(async () => {
|
33 |
try {
|
|
|
43 |
} else {
|
44 |
setDocs(null)
|
45 |
}
|
|
|
46 |
} else {
|
47 |
setDocs(null)
|
48 |
}
|
|
|
51 |
}
|
52 |
}, [setDocs, t])
|
53 |
|
54 |
+
// Fetch documents when the tab becomes visible
|
55 |
useEffect(() => {
|
56 |
+
if (currentTab === 'documents') {
|
57 |
fetchDocuments()
|
|
|
58 |
}
|
59 |
+
}, [currentTab, fetchDocuments])
|
60 |
|
61 |
const scanDocuments = useCallback(async () => {
|
62 |
try {
|
|
|
67 |
}
|
68 |
}, [t])
|
69 |
|
70 |
+
// Set up polling when the documents tab is active and health is good
|
71 |
useEffect(() => {
|
72 |
+
if (currentTab !== 'documents' || !health) {
|
73 |
return
|
74 |
}
|
75 |
|
|
|
82 |
}, 5000)
|
83 |
|
84 |
return () => clearInterval(interval)
|
85 |
+
}, [health, fetchDocuments, t, currentTab])
|
86 |
|
87 |
return (
|
88 |
<Card className="!size-full !rounded-none !border-none">
|
lightrag_webui/src/features/GraphViewer.tsx
CHANGED
@@ -1,5 +1,4 @@
|
|
1 |
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
2 |
-
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
3 |
// import { MiniMap } from '@react-sigma/minimap'
|
4 |
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
5 |
import { Settings as SigmaSettings } from 'sigma/settings'
|
@@ -108,46 +107,46 @@ const GraphEvents = () => {
|
|
108 |
const GraphViewer = () => {
|
109 |
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
|
110 |
const sigmaRef = useRef<any>(null)
|
111 |
-
const initAttemptedRef = useRef(false)
|
112 |
|
113 |
const selectedNode = useGraphStore.use.selectedNode()
|
114 |
const focusedNode = useGraphStore.use.focusedNode()
|
115 |
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
116 |
const isFetching = useGraphStore.use.isFetching()
|
117 |
-
const shouldRender = useGraphStore.use.shouldRender() // Rendering control state
|
118 |
-
|
119 |
-
// Get tab visibility
|
120 |
-
const { isTabVisible } = useTabVisibility()
|
121 |
-
const isGraphTabVisible = isTabVisible('knowledge-graph')
|
122 |
|
123 |
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
124 |
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
125 |
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
126 |
|
127 |
-
// Handle component mount/unmount and tab visibility
|
128 |
-
useEffect(() => {
|
129 |
-
// When component mounts or tab becomes visible
|
130 |
-
if (isGraphTabVisible && !shouldRender && !isFetching && !initAttemptedRef.current) {
|
131 |
-
// If tab is visible but graph is not rendering, try to enable rendering
|
132 |
-
useGraphStore.getState().setShouldRender(true)
|
133 |
-
initAttemptedRef.current = true
|
134 |
-
console.log('Graph viewer initialized')
|
135 |
-
}
|
136 |
-
|
137 |
-
// Cleanup function when component unmounts
|
138 |
-
return () => {
|
139 |
-
// Only log cleanup, don't actually clean up the WebGL context
|
140 |
-
// This allows the WebGL context to persist across tab switches
|
141 |
-
console.log('Graph viewer cleanup')
|
142 |
-
}
|
143 |
-
}, [isGraphTabVisible, shouldRender, isFetching])
|
144 |
-
|
145 |
// Initialize sigma settings once on component mount
|
146 |
// All dynamic settings will be updated in GraphControl using useSetSettings
|
147 |
useEffect(() => {
|
148 |
setSigmaSettings(defaultSigmaSettings)
|
|
|
149 |
}, [])
|
150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
151 |
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
|
152 |
if (value === null) useGraphStore.getState().setFocusedNode(null)
|
153 |
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
|
@@ -167,62 +166,51 @@ const GraphViewer = () => {
|
|
167 |
[selectedNode]
|
168 |
)
|
169 |
|
170 |
-
//
|
171 |
-
// the SigmaContainer based on visibility to avoid unnecessary rendering
|
172 |
return (
|
173 |
-
<div className="relative h-full w-full">
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
<
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
)}
|
196 |
-
</div>
|
197 |
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
|
|
|
|
|
|
|
|
|
|
204 |
</div>
|
|
|
205 |
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
</div>
|
210 |
-
)}
|
211 |
|
212 |
-
|
213 |
-
|
214 |
-
</div> */}
|
215 |
-
|
216 |
-
<SettingsDisplay />
|
217 |
-
</SigmaContainer>
|
218 |
-
) : (
|
219 |
-
// Placeholder when tab is not visible
|
220 |
-
<div className="flex h-full w-full items-center justify-center">
|
221 |
-
<div className="text-center text-muted-foreground">
|
222 |
-
{/* Placeholder content */}
|
223 |
-
</div>
|
224 |
-
</div>
|
225 |
-
)}
|
226 |
|
227 |
{/* Loading overlay - shown when data is loading */}
|
228 |
{isFetching && (
|
|
|
1 |
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
|
|
2 |
// import { MiniMap } from '@react-sigma/minimap'
|
3 |
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
4 |
import { Settings as SigmaSettings } from 'sigma/settings'
|
|
|
107 |
const GraphViewer = () => {
|
108 |
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
|
109 |
const sigmaRef = useRef<any>(null)
|
|
|
110 |
|
111 |
const selectedNode = useGraphStore.use.selectedNode()
|
112 |
const focusedNode = useGraphStore.use.focusedNode()
|
113 |
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
114 |
const isFetching = useGraphStore.use.isFetching()
|
|
|
|
|
|
|
|
|
|
|
115 |
|
116 |
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
117 |
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
118 |
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
120 |
// Initialize sigma settings once on component mount
|
121 |
// All dynamic settings will be updated in GraphControl using useSetSettings
|
122 |
useEffect(() => {
|
123 |
setSigmaSettings(defaultSigmaSettings)
|
124 |
+
console.log('Initialized sigma settings')
|
125 |
}, [])
|
126 |
|
127 |
+
// Clean up sigma instance when component unmounts
|
128 |
+
useEffect(() => {
|
129 |
+
return () => {
|
130 |
+
// TAB is mount twice in vite dev mode, this is a workaround
|
131 |
+
|
132 |
+
const sigma = useGraphStore.getState().sigmaInstance;
|
133 |
+
if (sigma) {
|
134 |
+
try {
|
135 |
+
// Destroy sigma,and clear WebGL context
|
136 |
+
sigma.kill();
|
137 |
+
useGraphStore.getState().setSigmaInstance(null);
|
138 |
+
console.log('Cleared sigma instance on Graphviewer unmount');
|
139 |
+
} catch (error) {
|
140 |
+
console.error('Error cleaning up sigma instance:', error);
|
141 |
+
}
|
142 |
+
}
|
143 |
+
};
|
144 |
+
}, []);
|
145 |
+
|
146 |
+
// Note: There was a useLayoutEffect hook here to set up the sigma instance and graph data,
|
147 |
+
// but testing showed it wasn't executing or having any effect, while the backup mechanism
|
148 |
+
// in GraphControl was sufficient. This code was removed to simplify implementation
|
149 |
+
|
150 |
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
|
151 |
if (value === null) useGraphStore.getState().setFocusedNode(null)
|
152 |
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
|
|
|
166 |
[selectedNode]
|
167 |
)
|
168 |
|
169 |
+
// Always render SigmaContainer but control its visibility with CSS
|
|
|
170 |
return (
|
171 |
+
<div className="relative h-full w-full overflow-hidden">
|
172 |
+
<SigmaContainer
|
173 |
+
settings={sigmaSettings}
|
174 |
+
className="!bg-background !size-full overflow-hidden"
|
175 |
+
ref={sigmaRef}
|
176 |
+
>
|
177 |
+
<GraphControl />
|
178 |
+
|
179 |
+
{enableNodeDrag && <GraphEvents />}
|
180 |
+
|
181 |
+
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
182 |
+
|
183 |
+
<div className="absolute top-2 left-2 flex items-start gap-2">
|
184 |
+
<GraphLabels />
|
185 |
+
{showNodeSearchBar && (
|
186 |
+
<GraphSearch
|
187 |
+
value={searchInitSelectedNode}
|
188 |
+
onFocus={onSearchFocus}
|
189 |
+
onChange={onSearchSelect}
|
190 |
+
/>
|
191 |
+
)}
|
192 |
+
</div>
|
|
|
|
|
193 |
|
194 |
+
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
195 |
+
<LayoutsControl />
|
196 |
+
<ZoomControl />
|
197 |
+
<FullScreenControl />
|
198 |
+
<Settings />
|
199 |
+
{/* <ThemeToggle /> */}
|
200 |
+
</div>
|
201 |
+
|
202 |
+
{showPropertyPanel && (
|
203 |
+
<div className="absolute top-2 right-2">
|
204 |
+
<PropertiesView />
|
205 |
</div>
|
206 |
+
)}
|
207 |
|
208 |
+
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
209 |
+
<MiniMap width="100px" height="100px" />
|
210 |
+
</div> */}
|
|
|
|
|
211 |
|
212 |
+
<SettingsDisplay />
|
213 |
+
</SigmaContainer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
214 |
|
215 |
{/* Loading overlay - shown when data is loading */}
|
216 |
{isFetching && (
|
lightrag_webui/src/features/LoginPage.tsx
ADDED
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect } from 'react'
|
2 |
+
import { useNavigate } from 'react-router-dom'
|
3 |
+
import { useAuthStore } from '@/stores/state'
|
4 |
+
import { loginToServer, getAuthStatus } from '@/api/lightrag'
|
5 |
+
import { toast } from 'sonner'
|
6 |
+
import { useTranslation } from 'react-i18next'
|
7 |
+
import { Card, CardContent, CardHeader } from '@/components/ui/Card'
|
8 |
+
import Input from '@/components/ui/Input'
|
9 |
+
import Button from '@/components/ui/Button'
|
10 |
+
import { ZapIcon } from 'lucide-react'
|
11 |
+
import AppSettings from '@/components/AppSettings'
|
12 |
+
|
13 |
+
const LoginPage = () => {
|
14 |
+
const navigate = useNavigate()
|
15 |
+
const { login, isAuthenticated } = useAuthStore()
|
16 |
+
const { t } = useTranslation()
|
17 |
+
const [loading, setLoading] = useState(false)
|
18 |
+
const [username, setUsername] = useState('')
|
19 |
+
const [password, setPassword] = useState('')
|
20 |
+
const [checkingAuth, setCheckingAuth] = useState(true)
|
21 |
+
|
22 |
+
useEffect(() => {
|
23 |
+
console.log('LoginPage mounted')
|
24 |
+
}, []);
|
25 |
+
|
26 |
+
// Check if authentication is configured, skip login if not
|
27 |
+
useEffect(() => {
|
28 |
+
let isMounted = true; // Flag to prevent state updates after unmount
|
29 |
+
|
30 |
+
const checkAuthConfig = async () => {
|
31 |
+
try {
|
32 |
+
// If already authenticated, redirect to home
|
33 |
+
if (isAuthenticated) {
|
34 |
+
navigate('/')
|
35 |
+
return
|
36 |
+
}
|
37 |
+
|
38 |
+
// Check auth status
|
39 |
+
const status = await getAuthStatus()
|
40 |
+
|
41 |
+
// Only proceed if component is still mounted
|
42 |
+
if (!isMounted) return;
|
43 |
+
|
44 |
+
if (!status.auth_configured && status.access_token) {
|
45 |
+
// If auth is not configured, use the guest token and redirect
|
46 |
+
login(status.access_token, true)
|
47 |
+
if (status.message) {
|
48 |
+
toast.info(status.message)
|
49 |
+
}
|
50 |
+
navigate('/')
|
51 |
+
return // Exit early, no need to set checkingAuth to false
|
52 |
+
}
|
53 |
+
} catch (error) {
|
54 |
+
console.error('Failed to check auth configuration:', error)
|
55 |
+
} finally {
|
56 |
+
// Only update state if component is still mounted
|
57 |
+
if (isMounted) {
|
58 |
+
setCheckingAuth(false)
|
59 |
+
}
|
60 |
+
}
|
61 |
+
}
|
62 |
+
|
63 |
+
// Execute immediately
|
64 |
+
checkAuthConfig()
|
65 |
+
|
66 |
+
// Cleanup function to prevent state updates after unmount
|
67 |
+
return () => {
|
68 |
+
isMounted = false;
|
69 |
+
}
|
70 |
+
}, [isAuthenticated, login, navigate])
|
71 |
+
|
72 |
+
// Don't render anything while checking auth
|
73 |
+
if (checkingAuth) {
|
74 |
+
return null
|
75 |
+
}
|
76 |
+
|
77 |
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
78 |
+
e.preventDefault()
|
79 |
+
if (!username || !password) {
|
80 |
+
toast.error(t('login.errorEmptyFields'))
|
81 |
+
return
|
82 |
+
}
|
83 |
+
|
84 |
+
try {
|
85 |
+
setLoading(true)
|
86 |
+
const response = await loginToServer(username, password)
|
87 |
+
|
88 |
+
// Check authentication mode
|
89 |
+
const isGuestMode = response.auth_mode === 'disabled'
|
90 |
+
login(response.access_token, isGuestMode)
|
91 |
+
|
92 |
+
if (isGuestMode) {
|
93 |
+
// Show authentication disabled notification
|
94 |
+
toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))
|
95 |
+
} else {
|
96 |
+
toast.success(t('login.successMessage'))
|
97 |
+
}
|
98 |
+
|
99 |
+
// Navigate to home page after successful login
|
100 |
+
navigate('/')
|
101 |
+
} catch (error) {
|
102 |
+
console.error('Login failed...', error)
|
103 |
+
toast.error(t('login.errorInvalidCredentials'))
|
104 |
+
|
105 |
+
// Clear any existing auth state
|
106 |
+
useAuthStore.getState().logout()
|
107 |
+
// Clear local storage
|
108 |
+
localStorage.removeItem('LIGHTRAG-API-TOKEN')
|
109 |
+
} finally {
|
110 |
+
setLoading(false)
|
111 |
+
}
|
112 |
+
}
|
113 |
+
|
114 |
+
return (
|
115 |
+
<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">
|
116 |
+
<div className="absolute top-4 right-4 flex items-center gap-2">
|
117 |
+
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
|
118 |
+
</div>
|
119 |
+
<Card className="w-full max-w-[480px] shadow-lg mx-4">
|
120 |
+
<CardHeader className="flex items-center justify-center space-y-2 pb-8 pt-6">
|
121 |
+
<div className="flex flex-col items-center space-y-4">
|
122 |
+
<div className="flex items-center gap-3">
|
123 |
+
<img src="logo.png" alt="LightRAG Logo" className="h-12 w-12" />
|
124 |
+
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" />
|
125 |
+
</div>
|
126 |
+
<div className="text-center space-y-2">
|
127 |
+
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
|
128 |
+
<p className="text-muted-foreground text-sm">
|
129 |
+
{t('login.description')}
|
130 |
+
</p>
|
131 |
+
</div>
|
132 |
+
</div>
|
133 |
+
</CardHeader>
|
134 |
+
<CardContent className="px-8 pb-8">
|
135 |
+
<form onSubmit={handleSubmit} className="space-y-6">
|
136 |
+
<div className="flex items-center gap-4">
|
137 |
+
<label htmlFor="username" className="text-sm font-medium w-16 shrink-0">
|
138 |
+
{t('login.username')}
|
139 |
+
</label>
|
140 |
+
<Input
|
141 |
+
id="username"
|
142 |
+
placeholder={t('login.usernamePlaceholder')}
|
143 |
+
value={username}
|
144 |
+
onChange={(e) => setUsername(e.target.value)}
|
145 |
+
required
|
146 |
+
className="h-11 flex-1"
|
147 |
+
/>
|
148 |
+
</div>
|
149 |
+
<div className="flex items-center gap-4">
|
150 |
+
<label htmlFor="password" className="text-sm font-medium w-16 shrink-0">
|
151 |
+
{t('login.password')}
|
152 |
+
</label>
|
153 |
+
<Input
|
154 |
+
id="password"
|
155 |
+
type="password"
|
156 |
+
placeholder={t('login.passwordPlaceholder')}
|
157 |
+
value={password}
|
158 |
+
onChange={(e) => setPassword(e.target.value)}
|
159 |
+
required
|
160 |
+
className="h-11 flex-1"
|
161 |
+
/>
|
162 |
+
</div>
|
163 |
+
<Button
|
164 |
+
type="submit"
|
165 |
+
className="w-full h-11 text-base font-medium mt-2"
|
166 |
+
disabled={loading}
|
167 |
+
>
|
168 |
+
{loading ? t('login.loggingIn') : t('login.loginButton')}
|
169 |
+
</Button>
|
170 |
+
</form>
|
171 |
+
</CardContent>
|
172 |
+
</Card>
|
173 |
+
</div>
|
174 |
+
)
|
175 |
+
}
|
176 |
+
|
177 |
+
export default LoginPage
|
lightrag_webui/src/features/RetrievalTesting.tsx
CHANGED
@@ -112,7 +112,7 @@ export default function RetrievalTesting() {
|
|
112 |
}, [setMessages])
|
113 |
|
114 |
return (
|
115 |
-
<div className="flex size-full gap-2 px-2 pb-12">
|
116 |
<div className="flex grow flex-col gap-4">
|
117 |
<div className="relative grow">
|
118 |
<div className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">
|
|
|
112 |
}, [setMessages])
|
113 |
|
114 |
return (
|
115 |
+
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
116 |
<div className="flex grow flex-col gap-4">
|
117 |
<div className="relative grow">
|
118 |
<div className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">
|