ParisNeo commited on
Commit
d1d2286
·
unverified ·
2 Parent(s): 2e875d6 1473fec

Merge branch 'HKUDS:main' into main

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -1
  2. README.md +17 -0
  3. env.example +7 -5
  4. lightrag/__init__.py +1 -1
  5. lightrag/api/auth.py +66 -7
  6. lightrag/api/gunicorn_config.py +3 -1
  7. lightrag/api/lightrag_server.py +44 -6
  8. lightrag/api/routers/document_routes.py +31 -11
  9. lightrag/api/run_with_gunicorn.py +2 -2
  10. lightrag/api/utils_api.py +44 -12
  11. lightrag/api/webui/assets/{index-DwcJE583.js → index-4I5HV9Fr.js} +0 -0
  12. lightrag/api/webui/assets/index-BSOt8Nur.css +0 -0
  13. lightrag/api/webui/assets/index-BV5s8k-a.css +0 -0
  14. lightrag/api/webui/index.html +0 -0
  15. lightrag/base.py +2 -0
  16. lightrag/kg/json_doc_status_impl.py +3 -0
  17. lightrag/kg/networkx_impl.py +3 -0
  18. lightrag/kg/postgres_impl.py +49 -14
  19. lightrag/kg/shared_storage.py +93 -2
  20. lightrag/lightrag.py +125 -64
  21. lightrag/llm/hf.py +16 -1
  22. lightrag/operate.py +75 -18
  23. lightrag/prompt.py +6 -4
  24. lightrag/utils.py +33 -25
  25. lightrag_webui/bun.lock +16 -0
  26. lightrag_webui/env.development.smaple +2 -0
  27. lightrag_webui/env.local.sample +3 -0
  28. lightrag_webui/index.html +1 -1
  29. lightrag_webui/package.json +2 -0
  30. lightrag_webui/src/App.tsx +6 -10
  31. lightrag_webui/src/AppRouter.tsx +190 -0
  32. lightrag_webui/src/api/lightrag.ts +104 -1
  33. lightrag_webui/src/components/AppSettings.tsx +7 -2
  34. lightrag_webui/src/components/LanguageToggle.tsx +49 -0
  35. lightrag_webui/src/components/graph/FocusOnNode.tsx +24 -10
  36. lightrag_webui/src/components/graph/GraphControl.tsx +63 -25
  37. lightrag_webui/src/components/graph/GraphLabels.tsx +73 -43
  38. lightrag_webui/src/components/graph/GraphSearch.tsx +51 -33
  39. lightrag_webui/src/components/graph/LayoutsControl.tsx +174 -21
  40. lightrag_webui/src/components/graph/PropertiesView.tsx +94 -19
  41. lightrag_webui/src/components/graph/Settings.tsx +28 -56
  42. lightrag_webui/src/components/graph/SettingsDisplay.tsx +1 -1
  43. lightrag_webui/src/components/graph/ZoomControl.tsx +79 -9
  44. lightrag_webui/src/components/ui/Popover.tsx +10 -12
  45. lightrag_webui/src/components/ui/Tooltip.tsx +1 -1
  46. lightrag_webui/src/contexts/TabVisibilityProvider.tsx +10 -4
  47. lightrag_webui/src/features/DocumentManager.tsx +9 -13
  48. lightrag_webui/src/features/GraphViewer.tsx +64 -76
  49. lightrag_webui/src/features/LoginPage.tsx +177 -0
  50. lightrag_webui/src/features/RetrievalTesting.tsx +1 -1
.gitattributes CHANGED
@@ -1,4 +1,5 @@
1
- lightrag/api/webui/** -diff
 
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
- # For jwt auth
155
- AUTH_USERNAME=admin # login name
156
- AUTH_PASSWORD=admin123 # password
157
- TOKEN_SECRET=your-key # JWT key
158
- TOKEN_EXPIRE_HOURS=4 # expire duration
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.6"
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) -> 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
- return payload["sub"]
 
 
 
 
 
 
 
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(os.getenv("TIMEOUT", 150)) # Default 150s to match run_with_gunicorn.py
 
 
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(".env", override=True)
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.post("/login")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- raise HTTPException(
351
- status_code=status.HTTP_501_NOT_IMPLEMENTED,
352
- detail="Authentication not configured",
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": auth_handler.create_token(username),
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 concurrently
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
- if len(file_paths) == 1:
420
- enqueued = await pipeline_enqueue_file(rag, file_paths[0])
421
- else:
422
- tasks = [pipeline_enqueue_file(rag, path) for path in file_paths]
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
- for idx, file_path in enumerate(new_files):
476
- try:
477
- await pipeline_index_file(rag, file_path)
478
- except Exception as e:
479
- logger.error(f"Error indexing file {file_path}: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(".env")
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(override=True)
20
 
21
  global_args = {"main_args": None}
22
 
@@ -35,19 +35,46 @@ ollama_server_infos = OllamaServerInfos()
35
 
36
 
37
  def get_auth_dependency():
38
- whitelist = os.getenv("WHITELIST_PATHS", "").split(",")
 
39
 
40
  async def dependency(
41
  request: Request,
42
  token: str = Depends(OAuth2PasswordBearer(tokenUrl="login", auto_error=False)),
43
  ):
44
- if request.url.path in whitelist:
 
 
 
 
 
 
45
  return
46
 
47
- if not (os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")):
 
48
  return
49
 
50
- auth_handler.validate_token(token)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(" ├─ Timeout: ", end="")
418
- ASCIIColors.yellow(f"{args.timeout if args.timeout else 'None (infinite)'}")
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(" └─ Model: ", end="")
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 Operations: ", end="")
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 DocProcessingStatus(
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 id"""
747
- raise NotImplementedError
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- chunk_id TEXT NULL,
 
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
- chunk_id TEXT NULL,
 
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(input, split_by_character, split_by_character_only, ids)
 
 
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, input: str | list[str], ids: list[str] | None = None
 
 
 
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 = {id_: doc for id_, doc in zip(ids, input)}
 
 
 
685
  else:
686
  # Clean input text and remove duplicates
687
- input = list(set(clean_text(doc) for doc in input))
688
- # Generate contents dict of MD5 hash IDs and documents
689
- contents = {compute_mdhash_id(doc, prefix="doc-"): doc for doc in input}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
 
691
  # 2. Remove duplicate contents
692
- unique_contents = {
693
- id_: content
694
- for content, id_ in {
695
- content: id_ for id_, content in contents.items()
696
- }.items()
 
 
 
 
 
 
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_, content in unique_contents.items()
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
- "updated_at": datetime.now().isoformat(),
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, custom_kg: dict[str, Any], full_doc_id: str = None
 
 
 
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
- doc_status = await self.doc_status.get_by_id(doc_id)
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 where this entity is the source
2433
- outgoing_edges = await self.chunk_entity_relation_graph.get_node_edges(
2434
  entity_name
2435
  )
2436
- if outgoing_edges:
2437
- for src, tgt in outgoing_edges:
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(("outgoing", src, tgt, edge_data))
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 rel_type, src, tgt, edge_data in all_relations:
 
 
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
- device = next(embed_model.parameters()).device
 
 
 
 
 
 
 
 
 
 
 
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
- metadata={"created_at": time.time()},
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
- metadata={"created_at": time.time()},
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(result: str, chunk_key: str):
 
 
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
- "metadata": {
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
- "metadata": {
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 = [["id", "entity", "type", "description", "rank"]]
 
 
1517
  for i, n in enumerate(use_entities):
1518
- created_at = e.get("created_at", "Unknown")
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. 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,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 Reserves 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,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 sesctions focusing on one main point or aspect of the answer
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" sesction. Clearly indicating whether each source is from Knowledge Graph (KG) or Vector Data (DC), in the following format: [KG/DC] Source content
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, will use current directory/lightrag.log
 
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
- file_handler = logging.handlers.RotatingFileHandler(
153
- filename=log_file_path,
154
- maxBytes=log_max_bytes,
155
- backupCount=log_backup_count,
156
- encoding="utf-8",
157
- )
158
- file_handler.setFormatter(detailed_formatter)
159
- file_handler.setLevel(level)
160
- logger_instance.addHandler(file_handler)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="/logo.png" />
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-x-hidden">
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
- // Interceptoradd api key
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
- export default function AppSettings() {
 
 
 
 
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="outline" size="icon" className="h-9 w-9">
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
- sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
19
- gotoNode(node)
 
 
 
 
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
- sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
 
 
 
 
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 { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
2
- import Graph from 'graphology'
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
- * => load the graph and apply layout
49
  */
50
  useEffect(() => {
51
- if (sigmaGraph) {
52
- loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>)
53
- assignLayout()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
- }, [assignLayout, loadGraph, sigmaGraph, maxIterations])
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
- if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
143
- newData.highlighted = true
144
- if (node === selectedNode) {
145
- newData.borderColor = Constants.nodeBorderColorSelected
 
 
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
- if (hideUnselectedEdges) {
178
- if (!graph.extremities(edge).includes(_focusedNode)) {
179
- newData.hidden = true
180
- }
181
- } else {
182
- if (graph.extremities(edge).includes(_focusedNode)) {
183
- newData.color = Constants.edgeColorHighlighted
 
 
184
  }
 
 
185
  }
186
  } else {
187
- if (focusedEdge || selectedEdge) {
188
- if (edge === selectedEdge) {
 
 
 
189
  newData.color = Constants.edgeColorSelected
190
- } else if (edge === focusedEdge) {
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 once on component mount, using global flag to prevent duplicates
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
- return (
87
- <AsyncSelect<string>
88
- className="ml-2"
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
- // select the last item means query all
104
- if (newLabel === '...') {
105
- newLabel = '*'
106
- }
107
 
108
- // Reset the fetch attempted flag to force a new data fetch
109
- useGraphStore.getState().setGraphDataFetchAttempted(false)
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
- // Clear current graph data to ensure complete reload when label changes
112
- if (newLabel !== currentLabel) {
113
- const graphStore = useGraphStore.getState();
114
- graphStore.clearSelection();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- // Reset the graph state but preserve the instance
117
- if (graphStore.sigmaGraph) {
118
- const nodes = Array.from(graphStore.sigmaGraph.nodes());
119
- nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
120
  }
121
- }
122
 
123
- if (newLabel === currentLabel && newLabel !== '*') {
124
- // reselect the same itme means qery all
125
- useSettingsStore.getState().setQueryLabel('*')
126
- } else {
127
  useSettingsStore.getState().setQueryLabel(newLabel)
128
- }
129
- }}
130
- clearable={false} // Prevent clearing value on reselect
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, useMemo } from 'react'
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
- interface OptionItem {
 
 
 
 
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' && <NodeById id={item.id} />}
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
- // Force reset the cache when graph changes
53
  useEffect(() => {
54
  if (graph) {
55
- // Reset cache to ensure fresh search results with new graph data
56
- lastGraph.graph = null;
57
- lastGraph.searchEngine = null;
58
  }
59
  }, [graph]);
60
 
61
- const searchEngine = useMemo(() => {
62
- if (lastGraph.graph == graph) {
63
- return lastGraph.searchEngine
 
 
64
  }
65
- if (!graph || graph.nodes().length == 0) return
66
-
67
- lastGraph.graph = graph
68
 
69
- const searchEngine = new MiniSearch({
 
70
  idField: 'id',
71
  fields: ['label'],
72
  searchOptions: {
@@ -78,16 +83,16 @@ export const GraphSearchInput = ({
78
  }
79
  })
80
 
81
- // Add documents
82
  const documents = graph.nodes().map((id: string) => ({
83
  id: id,
84
  label: graph.getNodeAttribute(id, 'label')
85
  }))
86
- searchEngine.addAll(documents)
87
 
88
- lastGraph.searchEngine = searchEngine
89
- return searchEngine
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
- // If no query, return first searchResultLimit nodes
 
 
 
 
 
 
 
 
 
 
101
  if (!query) {
102
- const nodeIds = graph.nodes().slice(0, searchResultLimit)
 
 
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).map((r: { id: string }) => ({
111
- id: r.id,
112
- type: 'nodes'
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
- const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
 
 
 
 
 
30
  const sigma = useSigma()
31
- const { stop, start, isRunning } = layout
 
 
 
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
- // we run the algo
43
  let timeout: number | null = null
44
  if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
45
- start()
46
- // set a timeout to stop it
47
- timeout =
48
- autoRunFor > 0
49
- ? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
50
- : null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
- //cleaning
54
  return () => {
55
- stop()
 
 
 
 
56
  if (timeout) {
57
- clearTimeout(timeout)
58
  }
 
59
  }
60
- }, [autoRunFor, start, stop, sigma])
61
 
62
  return (
63
  <Button
64
  size="icon"
65
- onClick={() => (isRunning ? stop() : start())}
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({ settings: { margin: 1 } })
89
- const layoutForce = useLayoutForce({ maxIterations: maxIterations })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- animateNodes(sigma.getGraph(), positions(), { duration: 500 })
136
- setLayout(newLayout)
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  },
138
  [layouts, sigma]
139
  )
@@ -142,7 +292,10 @@ const LayoutsControl = () => {
142
  <>
143
  <div>
144
  {layouts[layout] && 'worker' in layouts[layout] && (
145
- <WorkerLayoutControl layout={layouts[layout].worker!} />
 
 
 
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
- for (const edgeId of state.sigmaGraph.edges(node.id)) {
92
- const edge = state.rawGraph.getEdge(edgeId, true)
93
- if (edge) {
94
- const isTarget = node.id === edge.source
95
- const neighbourId = isTarget ? edge.target : edge.source
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
- const sourceNode = state.rawGraph?.getNode(edge.source)
116
- const targetNode = state.rawGraph?.getNode(edge.target)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <label className="text-md pl-1 font-bold tracking-wide text-sky-300">{t('graphPanel.propertiesView.node.title')}</label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-yellow-400/90">{t('graphPanel.propertiesView.node.properties')}</label>
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-teal-600/90">
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-teal-600">{t('graphPanel.propertiesView.edge.title')}</label>
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-yellow-400/90">{t('graphPanel.propertiesView.edge.properties')}</label>
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, useEffect } 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,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, RefreshCwIcon } from 'lucide-react'
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 variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon">
 
 
 
 
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-2 left-[calc(2rem+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>
 
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 "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 { t } = useTranslation();
14
 
15
  const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
16
  const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
17
- const handleResetZoom = useCallback(() => reset(), [reset])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  return (
20
  <>
21
- <Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t("graphPanel.sideBar.zoomControl.zoomIn")} size="icon">
22
- <ZoomInIcon />
 
 
 
 
 
23
  </Button>
24
- <Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t("graphPanel.sideBar.zoomControl.zoomOut")} size="icon">
25
- <ZoomOutIcon />
 
 
 
 
 
26
  </Button>
27
  <Button
28
  variant={controlButtonVariant}
29
  onClick={handleResetZoom}
30
- tooltip={t("graphPanel.sideBar.zoomControl.resetZoom")}
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.Portal>
15
- <PopoverPrimitive.Content
16
- ref={ref}
17
- align={align}
18
- sideOffset={sideOffset}
19
- className={cn(
20
- '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',
21
- className
22
- )}
23
- {...props}
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 current tab as visible
19
  const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
20
- [currentTab]: true
 
 
 
21
  }));
22
 
23
- // Update visibility when current tab changes
24
  useEffect(() => {
25
  setVisibleTabs((prev) => ({
26
  ...prev,
27
- [currentTab]: true
 
 
 
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, useRef } from 'react'
2
  import { useTranslation } from 'react-i18next'
3
- import { useTabVisibility } from '@/contexts/useTabVisibility'
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 { isTabVisible } = useTabVisibility()
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
- // Only fetch documents when the tab becomes visible for the first time
58
  useEffect(() => {
59
- if (isDocumentsTabVisible && !initialLoadRef.current) {
60
  fetchDocuments()
61
- initialLoadRef.current = true
62
  }
63
- }, [isDocumentsTabVisible, fetchDocuments])
64
 
65
  const scanDocuments = useCallback(async () => {
66
  try {
@@ -71,9 +67,9 @@ export default function DocumentManager() {
71
  }
72
  }, [t])
73
 
74
- // Only set up polling when the tab is visible and health is good
75
  useEffect(() => {
76
- if (!isDocumentsTabVisible || !health) {
77
  return
78
  }
79
 
@@ -86,7 +82,7 @@ export default function DocumentManager() {
86
  }, 5000)
87
 
88
  return () => clearInterval(interval)
89
- }, [health, fetchDocuments, t, isDocumentsTabVisible])
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
- // Since TabsContent now forces mounting of all tabs, we need to conditionally render
171
- // the SigmaContainer based on visibility to avoid unnecessary rendering
172
  return (
173
- <div className="relative h-full w-full">
174
- {/* Only render the SigmaContainer when the tab is visible */}
175
- {isGraphTabVisible ? (
176
- <SigmaContainer
177
- settings={sigmaSettings}
178
- className="!bg-background !size-full overflow-hidden"
179
- ref={sigmaRef}
180
- >
181
- <GraphControl />
182
-
183
- {enableNodeDrag && <GraphEvents />}
184
-
185
- <FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
186
-
187
- <div className="absolute top-2 left-2 flex items-start gap-2">
188
- <GraphLabels />
189
- {showNodeSearchBar && (
190
- <GraphSearch
191
- value={searchInitSelectedNode}
192
- onFocus={onSearchFocus}
193
- onChange={onSearchSelect}
194
- />
195
- )}
196
- </div>
197
 
198
- <div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
199
- <Settings />
200
- <ZoomControl />
201
- <LayoutsControl />
202
- <FullScreenControl />
203
- {/* <ThemeToggle /> */}
 
 
 
 
 
204
  </div>
 
205
 
206
- {showPropertyPanel && (
207
- <div className="absolute top-2 right-2">
208
- <PropertiesView />
209
- </div>
210
- )}
211
 
212
- {/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
213
- <MiniMap width="100px" height="100px" />
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">