Merge branch 'HKUDS:main' into main

This commit is contained in:
ArindamRoy23
2025-03-11 20:53:00 +05:30
committed by GitHub
46 changed files with 2595 additions and 923 deletions

View File

@@ -1,5 +1,5 @@
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
__version__ = "1.2.4" __version__ = "1.2.5"
__author__ = "Zirui Guo" __author__ = "Zirui Guo"
__url__ = "https://github.com/HKUDS/LightRAG" __url__ = "https://github.com/HKUDS/LightRAG"

View File

@@ -50,9 +50,6 @@ from .auth import auth_handler
# This update allows the user to put a different.env file for each lightrag folder # This update allows the user to put a different.env file for each lightrag folder
load_dotenv(".env", override=True) load_dotenv(".env", override=True)
# Read entity extraction cache config
enable_llm_cache = os.getenv("ENABLE_LLM_CACHE_FOR_EXTRACT", "false").lower() == "true"
# Initialize config parser # Initialize config parser
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read("config.ini") config.read("config.ini")
@@ -144,23 +141,25 @@ def create_app(args):
try: try:
# Initialize database connections # Initialize database connections
await rag.initialize_storages() await rag.initialize_storages()
await initialize_pipeline_status()
await initialize_pipeline_status()
pipeline_status = await get_namespace_data("pipeline_status")
should_start_autoscan = False
async with get_pipeline_status_lock():
# Auto scan documents if enabled # Auto scan documents if enabled
if args.auto_scan_at_startup: if args.auto_scan_at_startup:
# Check if a task is already running (with lock protection) if not pipeline_status.get("autoscanned", False):
pipeline_status = await get_namespace_data("pipeline_status") pipeline_status["autoscanned"] = True
should_start_task = False should_start_autoscan = True
async with get_pipeline_status_lock():
if not pipeline_status.get("busy", False): # Only run auto scan when no other process started it first
should_start_task = True if should_start_autoscan:
# Only start the task if no other task is running
if should_start_task:
# Create background task # Create background task
task = asyncio.create_task(run_scanning_process(rag, doc_manager)) task = asyncio.create_task(run_scanning_process(rag, doc_manager))
app.state.background_tasks.add(task) app.state.background_tasks.add(task)
task.add_done_callback(app.state.background_tasks.discard) task.add_done_callback(app.state.background_tasks.discard)
logger.info("Auto scan task started at startup.") logger.info(f"Process {os.getpid()} auto scan task started at startup.")
ASCIIColors.green("\nServer is ready to accept connections! 🚀\n") ASCIIColors.green("\nServer is ready to accept connections! 🚀\n")
@@ -326,7 +325,7 @@ def create_app(args):
vector_db_storage_cls_kwargs={ vector_db_storage_cls_kwargs={
"cosine_better_than_threshold": args.cosine_threshold "cosine_better_than_threshold": args.cosine_threshold
}, },
enable_llm_cache_for_entity_extract=enable_llm_cache, # Read from environment variable enable_llm_cache_for_entity_extract=args.enable_llm_cache_for_extract,
embedding_cache_config={ embedding_cache_config={
"enabled": True, "enabled": True,
"similarity_threshold": 0.95, "similarity_threshold": 0.95,
@@ -355,7 +354,7 @@ def create_app(args):
vector_db_storage_cls_kwargs={ vector_db_storage_cls_kwargs={
"cosine_better_than_threshold": args.cosine_threshold "cosine_better_than_threshold": args.cosine_threshold
}, },
enable_llm_cache_for_entity_extract=enable_llm_cache, # Read from environment variable enable_llm_cache_for_entity_extract=args.enable_llm_cache_for_extract,
embedding_cache_config={ embedding_cache_config={
"enabled": True, "enabled": True,
"similarity_threshold": 0.95, "similarity_threshold": 0.95,
@@ -419,6 +418,7 @@ def create_app(args):
"doc_status_storage": args.doc_status_storage, "doc_status_storage": args.doc_status_storage,
"graph_storage": args.graph_storage, "graph_storage": args.graph_storage,
"vector_storage": args.vector_storage, "vector_storage": args.vector_storage,
"enable_llm_cache_for_extract": args.enable_llm_cache_for_extract,
}, },
"update_status": update_status, "update_status": update_status,
} }

View File

@@ -16,7 +16,11 @@ from pydantic import BaseModel, Field, field_validator
from lightrag import LightRAG from lightrag import LightRAG
from lightrag.base import DocProcessingStatus, DocStatus from lightrag.base import DocProcessingStatus, DocStatus
from ..utils_api import get_api_key_dependency, get_auth_dependency from lightrag.api.utils_api import (
get_api_key_dependency,
global_args,
get_auth_dependency,
)
router = APIRouter( router = APIRouter(
prefix="/documents", prefix="/documents",
@@ -240,6 +244,15 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
) )
return False return False
case ".pdf": case ".pdf":
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert(file_path)
content = result.document.export_to_markdown()
else:
if not pm.is_installed("pypdf2"): # type: ignore if not pm.is_installed("pypdf2"): # type: ignore
pm.install("pypdf2") pm.install("pypdf2")
from PyPDF2 import PdfReader # type: ignore from PyPDF2 import PdfReader # type: ignore
@@ -250,6 +263,15 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
for page in reader.pages: for page in reader.pages:
content += page.extract_text() + "\n" content += page.extract_text() + "\n"
case ".docx": case ".docx":
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert(file_path)
content = result.document.export_to_markdown()
else:
if not pm.is_installed("python-docx"): # type: ignore if not pm.is_installed("python-docx"): # type: ignore
pm.install("docx") pm.install("docx")
from docx import Document # type: ignore from docx import Document # type: ignore
@@ -257,8 +279,19 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
docx_file = BytesIO(file) docx_file = BytesIO(file)
doc = Document(docx_file) doc = Document(docx_file)
content = "\n".join([paragraph.text for paragraph in doc.paragraphs]) content = "\n".join(
[paragraph.text for paragraph in doc.paragraphs]
)
case ".pptx": case ".pptx":
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert(file_path)
content = result.document.export_to_markdown()
else:
if not pm.is_installed("python-pptx"): # type: ignore if not pm.is_installed("python-pptx"): # type: ignore
pm.install("pptx") pm.install("pptx")
from pptx import Presentation # type: ignore from pptx import Presentation # type: ignore
@@ -271,6 +304,15 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
if hasattr(shape, "text"): if hasattr(shape, "text"):
content += shape.text + "\n" content += shape.text + "\n"
case ".xlsx": case ".xlsx":
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert(file_path)
content = result.document.export_to_markdown()
else:
if not pm.is_installed("openpyxl"): # type: ignore if not pm.is_installed("openpyxl"): # type: ignore
pm.install("openpyxl") pm.install("openpyxl")
from openpyxl import load_workbook # type: ignore from openpyxl import load_workbook # type: ignore
@@ -283,7 +325,8 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
for row in sheet.iter_rows(values_only=True): for row in sheet.iter_rows(values_only=True):
content += ( content += (
"\t".join( "\t".join(
str(cell) if cell is not None else "" for cell in row str(cell) if cell is not None else ""
for cell in row
) )
+ "\n" + "\n"
) )

View File

@@ -11,7 +11,7 @@ import asyncio
from ascii_colors import trace_exception from ascii_colors import trace_exception
from lightrag import LightRAG, QueryParam from lightrag import LightRAG, QueryParam
from lightrag.utils import encode_string_by_tiktoken from lightrag.utils import encode_string_by_tiktoken
from ..utils_api import ollama_server_infos from lightrag.api.utils_api import ollama_server_infos
# query mode according to query prefix (bypass is not LightRAG quer mode) # query mode according to query prefix (bypass is not LightRAG quer mode)

View File

@@ -18,6 +18,8 @@ from .auth import auth_handler
# Load environment variables # Load environment variables
load_dotenv(override=True) load_dotenv(override=True)
global_args = {"main_args": None}
class OllamaServerInfos: class OllamaServerInfos:
# Constants for emulated Ollama model information # Constants for emulated Ollama model information
@@ -360,8 +362,17 @@ def parse_args(is_uvicorn_mode: bool = False) -> argparse.Namespace:
args.chunk_size = get_env_value("CHUNK_SIZE", 1200, int) args.chunk_size = get_env_value("CHUNK_SIZE", 1200, int)
args.chunk_overlap_size = get_env_value("CHUNK_OVERLAP_SIZE", 100, int) args.chunk_overlap_size = get_env_value("CHUNK_OVERLAP_SIZE", 100, int)
# Inject LLM cache configuration
args.enable_llm_cache_for_extract = get_env_value(
"ENABLE_LLM_CACHE_FOR_EXTRACT", False, bool
)
# Select Document loading tool (DOCLING, DEFAULT)
args.document_loading_engine = get_env_value("DOCUMENT_LOADING_ENGINE", "DEFAULT")
ollama_server_infos.LIGHTRAG_MODEL = args.simulated_model_name ollama_server_infos.LIGHTRAG_MODEL = args.simulated_model_name
global_args["main_args"] = args
return args return args
@@ -451,8 +462,10 @@ def display_splash_screen(args: argparse.Namespace) -> None:
ASCIIColors.yellow(f"{args.history_turns}") ASCIIColors.yellow(f"{args.history_turns}")
ASCIIColors.white(" ├─ Cosine Threshold: ", end="") ASCIIColors.white(" ├─ Cosine Threshold: ", end="")
ASCIIColors.yellow(f"{args.cosine_threshold}") ASCIIColors.yellow(f"{args.cosine_threshold}")
ASCIIColors.white(" ─ Top-K: ", end="") ASCIIColors.white(" ─ Top-K: ", end="")
ASCIIColors.yellow(f"{args.top_k}") ASCIIColors.yellow(f"{args.top_k}")
ASCIIColors.white(" └─ LLM Cache for Extraction Enabled: ", end="")
ASCIIColors.yellow(f"{args.enable_llm_cache_for_extract}")
# System Configuration # System Configuration
ASCIIColors.magenta("\n💾 Storage Configuration:") ASCIIColors.magenta("\n💾 Storage Configuration:")

View File

@@ -127,6 +127,30 @@ class BaseVectorStorage(StorageNameSpace, ABC):
async def delete_entity_relation(self, entity_name: str) -> None: async def delete_entity_relation(self, entity_name: str) -> None:
"""Delete relations for a given entity.""" """Delete relations for a given entity."""
@abstractmethod
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
pass
@abstractmethod
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
pass
@dataclass @dataclass
class BaseKVStorage(StorageNameSpace, ABC): class BaseKVStorage(StorageNameSpace, ABC):

View File

@@ -271,3 +271,67 @@ class ChromaVectorDBStorage(BaseVectorStorage):
except Exception as e: except Exception as e:
logger.error(f"Error during prefix search in ChromaDB: {str(e)}") logger.error(f"Error during prefix search in ChromaDB: {str(e)}")
raise raise
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Query the collection for a single vector by ID
result = self._collection.get(
ids=[id], include=["metadatas", "embeddings", "documents"]
)
if not result or not result["ids"] or len(result["ids"]) == 0:
return None
# Format the result to match the expected structure
return {
"id": result["ids"][0],
"vector": result["embeddings"][0],
"content": result["documents"][0],
**result["metadatas"][0],
}
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Query the collection for multiple vectors by IDs
result = self._collection.get(
ids=ids, include=["metadatas", "embeddings", "documents"]
)
if not result or not result["ids"] or len(result["ids"]) == 0:
return []
# Format the results to match the expected structure
return [
{
"id": result["ids"][i],
"vector": result["embeddings"][i],
"content": result["documents"][i],
**result["metadatas"][i],
}
for i in range(len(result["ids"]))
]
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []

View File

@@ -394,3 +394,46 @@ class FaissVectorDBStorage(BaseVectorStorage):
logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'") logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'")
return matching_records return matching_records
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
# Find the Faiss internal ID for the custom ID
fid = self._find_faiss_id_by_custom_id(id)
if fid is None:
return None
# Get the metadata for the found ID
metadata = self._id_to_meta.get(fid, {})
if not metadata:
return None
return {**metadata, "id": metadata.get("__id__")}
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
results = []
for id in ids:
fid = self._find_faiss_id_by_custom_id(id)
if fid is not None:
metadata = self._id_to_meta.get(fid, {})
if metadata:
results.append({**metadata, "id": metadata.get("__id__")})
return results

View File

@@ -15,6 +15,10 @@ from lightrag.utils import (
from .shared_storage import ( from .shared_storage import (
get_namespace_data, get_namespace_data,
get_storage_lock, get_storage_lock,
get_data_init_lock,
get_update_flag,
set_all_update_flags,
clear_all_update_flags,
try_initialize_namespace, try_initialize_namespace,
) )
@@ -27,20 +31,24 @@ class JsonDocStatusStorage(DocStatusStorage):
def __post_init__(self): def __post_init__(self):
working_dir = self.global_config["working_dir"] working_dir = self.global_config["working_dir"]
self._file_name = os.path.join(working_dir, f"kv_store_{self.namespace}.json") self._file_name = os.path.join(working_dir, f"kv_store_{self.namespace}.json")
self._storage_lock = get_storage_lock()
self._data = None self._data = None
self._storage_lock = None
self.storage_updated = None
async def initialize(self): async def initialize(self):
"""Initialize storage data""" """Initialize storage data"""
self._storage_lock = get_storage_lock()
self.storage_updated = await get_update_flag(self.namespace)
async with get_data_init_lock():
# check need_init must before get_namespace_data # check need_init must before get_namespace_data
need_init = try_initialize_namespace(self.namespace) need_init = await try_initialize_namespace(self.namespace)
self._data = await get_namespace_data(self.namespace) self._data = await get_namespace_data(self.namespace)
if need_init: if need_init:
loaded_data = load_json(self._file_name) or {} loaded_data = load_json(self._file_name) or {}
async with self._storage_lock: async with self._storage_lock:
self._data.update(loaded_data) self._data.update(loaded_data)
logger.info( logger.info(
f"Loaded document status storage with {len(loaded_data)} records" f"Process {os.getpid()} doc status load {self.namespace} with {len(loaded_data)} records"
) )
async def filter_keys(self, keys: set[str]) -> set[str]: async def filter_keys(self, keys: set[str]) -> set[str]:
@@ -87,18 +95,24 @@ class JsonDocStatusStorage(DocStatusStorage):
async def index_done_callback(self) -> None: async def index_done_callback(self) -> None:
async with self._storage_lock: async with self._storage_lock:
if self.storage_updated.value:
data_dict = ( data_dict = (
dict(self._data) if hasattr(self._data, "_getvalue") else self._data dict(self._data) if hasattr(self._data, "_getvalue") else self._data
) )
logger.info(
f"Process {os.getpid()} doc status writting {len(data_dict)} records to {self.namespace}"
)
write_json(data_dict, self._file_name) write_json(data_dict, self._file_name)
await clear_all_update_flags(self.namespace)
async def upsert(self, data: dict[str, dict[str, Any]]) -> None: async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
logger.info(f"Inserting {len(data)} to {self.namespace}")
if not data: if not data:
return return
logger.info(f"Inserting {len(data)} records to {self.namespace}")
async with self._storage_lock: async with self._storage_lock:
self._data.update(data) self._data.update(data)
await set_all_update_flags(self.namespace)
await self.index_done_callback() await self.index_done_callback()
async def get_by_id(self, id: str) -> Union[dict[str, Any], None]: async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:
@@ -109,9 +123,12 @@ class JsonDocStatusStorage(DocStatusStorage):
async with self._storage_lock: async with self._storage_lock:
for doc_id in doc_ids: for doc_id in doc_ids:
self._data.pop(doc_id, None) self._data.pop(doc_id, None)
await set_all_update_flags(self.namespace)
await self.index_done_callback() await self.index_done_callback()
async def drop(self) -> None: async def drop(self) -> None:
"""Drop the storage""" """Drop the storage"""
async with self._storage_lock: async with self._storage_lock:
self._data.clear() self._data.clear()
await set_all_update_flags(self.namespace)
await self.index_done_callback()

View File

@@ -13,6 +13,10 @@ from lightrag.utils import (
from .shared_storage import ( from .shared_storage import (
get_namespace_data, get_namespace_data,
get_storage_lock, get_storage_lock,
get_data_init_lock,
get_update_flag,
set_all_update_flags,
clear_all_update_flags,
try_initialize_namespace, try_initialize_namespace,
) )
@@ -23,26 +27,63 @@ class JsonKVStorage(BaseKVStorage):
def __post_init__(self): def __post_init__(self):
working_dir = self.global_config["working_dir"] working_dir = self.global_config["working_dir"]
self._file_name = os.path.join(working_dir, f"kv_store_{self.namespace}.json") self._file_name = os.path.join(working_dir, f"kv_store_{self.namespace}.json")
self._storage_lock = get_storage_lock()
self._data = None self._data = None
self._storage_lock = None
self.storage_updated = None
async def initialize(self): async def initialize(self):
"""Initialize storage data""" """Initialize storage data"""
self._storage_lock = get_storage_lock()
self.storage_updated = await get_update_flag(self.namespace)
async with get_data_init_lock():
# check need_init must before get_namespace_data # check need_init must before get_namespace_data
need_init = try_initialize_namespace(self.namespace) need_init = await try_initialize_namespace(self.namespace)
self._data = await get_namespace_data(self.namespace) self._data = await get_namespace_data(self.namespace)
if need_init: if need_init:
loaded_data = load_json(self._file_name) or {} loaded_data = load_json(self._file_name) or {}
async with self._storage_lock: async with self._storage_lock:
self._data.update(loaded_data) self._data.update(loaded_data)
logger.info(f"Load KV {self.namespace} with {len(loaded_data)} data")
# Calculate data count based on namespace
if self.namespace.endswith("cache"):
# For cache namespaces, sum the cache entries across all cache types
data_count = sum(
len(first_level_dict)
for first_level_dict in loaded_data.values()
if isinstance(first_level_dict, dict)
)
else:
# For non-cache namespaces, use the original count method
data_count = len(loaded_data)
logger.info(
f"Process {os.getpid()} KV load {self.namespace} with {data_count} records"
)
async def index_done_callback(self) -> None: async def index_done_callback(self) -> None:
async with self._storage_lock: async with self._storage_lock:
if self.storage_updated.value:
data_dict = ( data_dict = (
dict(self._data) if hasattr(self._data, "_getvalue") else self._data dict(self._data) if hasattr(self._data, "_getvalue") else self._data
) )
# Calculate data count based on namespace
if self.namespace.endswith("cache"):
# # For cache namespaces, sum the cache entries across all cache types
data_count = sum(
len(first_level_dict)
for first_level_dict in data_dict.values()
if isinstance(first_level_dict, dict)
)
else:
# For non-cache namespaces, use the original count method
data_count = len(data_dict)
logger.info(
f"Process {os.getpid()} KV writting {data_count} records to {self.namespace}"
)
write_json(data_dict, self._file_name) write_json(data_dict, self._file_name)
await clear_all_update_flags(self.namespace)
async def get_all(self) -> dict[str, Any]: async def get_all(self) -> dict[str, Any]:
"""Get all data from storage """Get all data from storage
@@ -73,15 +114,16 @@ class JsonKVStorage(BaseKVStorage):
return set(keys) - set(self._data.keys()) return set(keys) - set(self._data.keys())
async def upsert(self, data: dict[str, dict[str, Any]]) -> None: async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
logger.info(f"Inserting {len(data)} to {self.namespace}")
if not data: if not data:
return return
logger.info(f"Inserting {len(data)} records to {self.namespace}")
async with self._storage_lock: async with self._storage_lock:
left_data = {k: v for k, v in data.items() if k not in self._data} self._data.update(data)
self._data.update(left_data) await set_all_update_flags(self.namespace)
async def delete(self, ids: list[str]) -> None: async def delete(self, ids: list[str]) -> None:
async with self._storage_lock: async with self._storage_lock:
for doc_id in ids: for doc_id in ids:
self._data.pop(doc_id, None) self._data.pop(doc_id, None)
await set_all_update_flags(self.namespace)
await self.index_done_callback() await self.index_done_callback()

View File

@@ -233,3 +233,57 @@ class MilvusVectorDBStorage(BaseVectorStorage):
except Exception as e: except Exception as e:
logger.error(f"Error searching for records with prefix '{prefix}': {e}") logger.error(f"Error searching for records with prefix '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Query Milvus for a specific ID
result = self._client.query(
collection_name=self.namespace,
filter=f'id == "{id}"',
output_fields=list(self.meta_fields) + ["id"],
)
if not result or len(result) == 0:
return None
return result[0]
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Prepare the ID filter expression
id_list = '", "'.join(ids)
filter_expr = f'id in ["{id_list}"]'
# Query Milvus with the filter
result = self._client.query(
collection_name=self.namespace,
filter=filter_expr,
output_fields=list(self.meta_fields) + ["id"],
)
return result or []
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []

View File

@@ -1073,6 +1073,59 @@ class MongoVectorDBStorage(BaseVectorStorage):
logger.error(f"Error searching by prefix in {self.namespace}: {str(e)}") logger.error(f"Error searching by prefix in {self.namespace}: {str(e)}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Search for the specific ID in MongoDB
result = await self._data.find_one({"_id": id})
if result:
# Format the result to include id field expected by API
result_dict = dict(result)
if "_id" in result_dict and "id" not in result_dict:
result_dict["id"] = result_dict["_id"]
return result_dict
return None
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Query MongoDB for multiple IDs
cursor = self._data.find({"_id": {"$in": ids}})
results = await cursor.to_list(length=None)
# Format results to include id field expected by API
formatted_results = []
for result in results:
result_dict = dict(result)
if "_id" in result_dict and "id" not in result_dict:
result_dict["id"] = result_dict["_id"]
formatted_results.append(result_dict)
return formatted_results
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
async def get_or_create_collection(db: AsyncIOMotorDatabase, collection_name: str): async def get_or_create_collection(db: AsyncIOMotorDatabase, collection_name: str):
collection_names = await db.list_collection_names() collection_names = await db.list_collection_names()

View File

@@ -258,3 +258,33 @@ class NanoVectorDBStorage(BaseVectorStorage):
logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'") logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'")
return matching_records return matching_records
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
client = await self._get_client()
result = client.get([id])
if result:
return result[0]
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
client = await self._get_client()
return client.get(ids)

File diff suppressed because it is too large Load Diff

View File

@@ -531,6 +531,80 @@ class OracleVectorDBStorage(BaseVectorStorage):
logger.error(f"Error searching records with prefix '{prefix}': {e}") logger.error(f"Error searching records with prefix '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Determine the table name based on namespace
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for ID lookup: {self.namespace}")
return None
# Create the appropriate ID field name based on namespace
id_field = "entity_id" if "NODES" in table_name else "relation_id"
if "CHUNKS" in table_name:
id_field = "chunk_id"
# Prepare and execute the query
query = f"""
SELECT * FROM {table_name}
WHERE {id_field} = :id AND workspace = :workspace
"""
params = {"id": id, "workspace": self.db.workspace}
result = await self.db.query(query, params)
return result
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Determine the table name based on namespace
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for IDs lookup: {self.namespace}")
return []
# Create the appropriate ID field name based on namespace
id_field = "entity_id" if "NODES" in table_name else "relation_id"
if "CHUNKS" in table_name:
id_field = "chunk_id"
# Format the list of IDs for SQL IN clause
ids_list = ", ".join([f"'{id}'" for id in ids])
# Prepare and execute the query
query = f"""
SELECT * FROM {table_name}
WHERE {id_field} IN ({ids_list}) AND workspace = :workspace
"""
params = {"workspace": self.db.workspace}
results = await self.db.query(query, params, multirows=True)
return results or []
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
@final @final
@dataclass @dataclass

View File

@@ -621,6 +621,60 @@ class PGVectorStorage(BaseVectorStorage):
logger.error(f"Error during prefix search for '{prefix}': {e}") logger.error(f"Error during prefix search for '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for ID lookup: {self.namespace}")
return None
query = f"SELECT * FROM {table_name} WHERE workspace=$1 AND id=$2"
params = {"workspace": self.db.workspace, "id": id}
try:
result = await self.db.query(query, params)
if result:
return dict(result)
return None
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for IDs lookup: {self.namespace}")
return []
ids_str = ",".join([f"'{id}'" for id in ids])
query = f"SELECT * FROM {table_name} WHERE workspace=$1 AND id IN ({ids_str})"
params = {"workspace": self.db.workspace}
try:
results = await self.db.query(query, params, multirows=True)
return [dict(record) for record in results]
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
@final @final
@dataclass @dataclass

View File

@@ -7,11 +7,17 @@ from typing import Any, Dict, Optional, Union, TypeVar, Generic
# Define a direct print function for critical logs that must be visible in all processes # Define a direct print function for critical logs that must be visible in all processes
def direct_log(message, level="INFO"): def direct_log(message, level="INFO", enable_output: bool = True):
""" """
Log a message directly to stderr to ensure visibility in all processes, Log a message directly to stderr to ensure visibility in all processes,
including the Gunicorn master process. including the Gunicorn master process.
Args:
message: The message to log
level: Log level (default: "INFO")
enable_output: Whether to actually output the log (default: True)
""" """
if enable_output:
print(f"{level}: {message}", file=sys.stderr, flush=True) print(f"{level}: {message}", file=sys.stderr, flush=True)
@@ -32,55 +38,165 @@ _update_flags: Optional[Dict[str, bool]] = None # namespace -> updated
_storage_lock: Optional[LockType] = None _storage_lock: Optional[LockType] = None
_internal_lock: Optional[LockType] = None _internal_lock: Optional[LockType] = None
_pipeline_status_lock: Optional[LockType] = None _pipeline_status_lock: Optional[LockType] = None
_graph_db_lock: Optional[LockType] = None
_data_init_lock: Optional[LockType] = None
class UnifiedLock(Generic[T]): class UnifiedLock(Generic[T]):
"""Provide a unified lock interface type for asyncio.Lock and multiprocessing.Lock""" """Provide a unified lock interface type for asyncio.Lock and multiprocessing.Lock"""
def __init__(self, lock: Union[ProcessLock, asyncio.Lock], is_async: bool): def __init__(
self,
lock: Union[ProcessLock, asyncio.Lock],
is_async: bool,
name: str = "unnamed",
enable_logging: bool = True,
):
self._lock = lock self._lock = lock
self._is_async = is_async self._is_async = is_async
self._pid = os.getpid() # for debug only
self._name = name # for debug only
self._enable_logging = enable_logging # for debug only
async def __aenter__(self) -> "UnifiedLock[T]": async def __aenter__(self) -> "UnifiedLock[T]":
try:
direct_log(
f"== Lock == Process {self._pid}: Acquiring lock '{self._name}' (async={self._is_async})",
enable_output=self._enable_logging,
)
if self._is_async: if self._is_async:
await self._lock.acquire() await self._lock.acquire()
else: else:
self._lock.acquire() self._lock.acquire()
direct_log(
f"== Lock == Process {self._pid}: Lock '{self._name}' acquired (async={self._is_async})",
enable_output=self._enable_logging,
)
return self return self
except Exception as e:
direct_log(
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}': {e}",
level="ERROR",
enable_output=self._enable_logging,
)
raise
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
try:
direct_log(
f"== Lock == Process {self._pid}: Releasing lock '{self._name}' (async={self._is_async})",
enable_output=self._enable_logging,
)
if self._is_async: if self._is_async:
self._lock.release() self._lock.release()
else: else:
self._lock.release() self._lock.release()
direct_log(
f"== Lock == Process {self._pid}: Lock '{self._name}' released (async={self._is_async})",
enable_output=self._enable_logging,
)
except Exception as e:
direct_log(
f"== Lock == Process {self._pid}: Failed to release lock '{self._name}': {e}",
level="ERROR",
enable_output=self._enable_logging,
)
raise
def __enter__(self) -> "UnifiedLock[T]": def __enter__(self) -> "UnifiedLock[T]":
"""For backward compatibility""" """For backward compatibility"""
try:
if self._is_async: if self._is_async:
raise RuntimeError("Use 'async with' for shared_storage lock") raise RuntimeError("Use 'async with' for shared_storage lock")
direct_log(
f"== Lock == Process {self._pid}: Acquiring lock '{self._name}' (sync)",
enable_output=self._enable_logging,
)
self._lock.acquire() self._lock.acquire()
direct_log(
f"== Lock == Process {self._pid}: Lock '{self._name}' acquired (sync)",
enable_output=self._enable_logging,
)
return self return self
except Exception as e:
direct_log(
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}' (sync): {e}",
level="ERROR",
enable_output=self._enable_logging,
)
raise
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
"""For backward compatibility""" """For backward compatibility"""
try:
if self._is_async: if self._is_async:
raise RuntimeError("Use 'async with' for shared_storage lock") raise RuntimeError("Use 'async with' for shared_storage lock")
direct_log(
f"== Lock == Process {self._pid}: Releasing lock '{self._name}' (sync)",
enable_output=self._enable_logging,
)
self._lock.release() self._lock.release()
direct_log(
f"== Lock == Process {self._pid}: Lock '{self._name}' released (sync)",
enable_output=self._enable_logging,
)
except Exception as e:
direct_log(
f"== Lock == Process {self._pid}: Failed to release lock '{self._name}' (sync): {e}",
level="ERROR",
enable_output=self._enable_logging,
)
raise
def get_internal_lock() -> UnifiedLock: def get_internal_lock(enable_logging: bool = False) -> UnifiedLock:
"""return unified storage lock for data consistency""" """return unified storage lock for data consistency"""
return UnifiedLock(lock=_internal_lock, is_async=not is_multiprocess) return UnifiedLock(
lock=_internal_lock,
is_async=not is_multiprocess,
name="internal_lock",
enable_logging=enable_logging,
)
def get_storage_lock() -> UnifiedLock: def get_storage_lock(enable_logging: bool = False) -> UnifiedLock:
"""return unified storage lock for data consistency""" """return unified storage lock for data consistency"""
return UnifiedLock(lock=_storage_lock, is_async=not is_multiprocess) return UnifiedLock(
lock=_storage_lock,
is_async=not is_multiprocess,
name="storage_lock",
enable_logging=enable_logging,
)
def get_pipeline_status_lock() -> UnifiedLock: def get_pipeline_status_lock(enable_logging: bool = False) -> UnifiedLock:
"""return unified storage lock for data consistency""" """return unified storage lock for data consistency"""
return UnifiedLock(lock=_pipeline_status_lock, is_async=not is_multiprocess) return UnifiedLock(
lock=_pipeline_status_lock,
is_async=not is_multiprocess,
name="pipeline_status_lock",
enable_logging=enable_logging,
)
def get_graph_db_lock(enable_logging: bool = False) -> UnifiedLock:
"""return unified graph database lock for ensuring atomic operations"""
return UnifiedLock(
lock=_graph_db_lock,
is_async=not is_multiprocess,
name="graph_db_lock",
enable_logging=enable_logging,
)
def get_data_init_lock(enable_logging: bool = False) -> UnifiedLock:
"""return unified data initialization lock for ensuring atomic data initialization"""
return UnifiedLock(
lock=_data_init_lock,
is_async=not is_multiprocess,
name="data_init_lock",
enable_logging=enable_logging,
)
def initialize_share_data(workers: int = 1): def initialize_share_data(workers: int = 1):
@@ -108,6 +224,8 @@ def initialize_share_data(workers: int = 1):
_storage_lock, \ _storage_lock, \
_internal_lock, \ _internal_lock, \
_pipeline_status_lock, \ _pipeline_status_lock, \
_graph_db_lock, \
_data_init_lock, \
_shared_dicts, \ _shared_dicts, \
_init_flags, \ _init_flags, \
_initialized, \ _initialized, \
@@ -120,14 +238,16 @@ def initialize_share_data(workers: int = 1):
) )
return return
_manager = Manager()
_workers = workers _workers = workers
if workers > 1: if workers > 1:
is_multiprocess = True is_multiprocess = True
_manager = Manager()
_internal_lock = _manager.Lock() _internal_lock = _manager.Lock()
_storage_lock = _manager.Lock() _storage_lock = _manager.Lock()
_pipeline_status_lock = _manager.Lock() _pipeline_status_lock = _manager.Lock()
_graph_db_lock = _manager.Lock()
_data_init_lock = _manager.Lock()
_shared_dicts = _manager.dict() _shared_dicts = _manager.dict()
_init_flags = _manager.dict() _init_flags = _manager.dict()
_update_flags = _manager.dict() _update_flags = _manager.dict()
@@ -139,6 +259,8 @@ def initialize_share_data(workers: int = 1):
_internal_lock = asyncio.Lock() _internal_lock = asyncio.Lock()
_storage_lock = asyncio.Lock() _storage_lock = asyncio.Lock()
_pipeline_status_lock = asyncio.Lock() _pipeline_status_lock = asyncio.Lock()
_graph_db_lock = asyncio.Lock()
_data_init_lock = asyncio.Lock()
_shared_dicts = {} _shared_dicts = {}
_init_flags = {} _init_flags = {}
_update_flags = {} _update_flags = {}
@@ -164,6 +286,7 @@ async def initialize_pipeline_status():
history_messages = _manager.list() if is_multiprocess else [] history_messages = _manager.list() if is_multiprocess else []
pipeline_namespace.update( pipeline_namespace.update(
{ {
"autoscanned": False, # Auto-scan started
"busy": False, # Control concurrent processes "busy": False, # Control concurrent processes
"job_name": "Default Job", # Current job name (indexing files/indexing texts) "job_name": "Default Job", # Current job name (indexing files/indexing texts)
"job_start": None, # Job start time "job_start": None, # Job start time
@@ -200,7 +323,12 @@ async def get_update_flag(namespace: str):
if is_multiprocess and _manager is not None: if is_multiprocess and _manager is not None:
new_update_flag = _manager.Value("b", False) new_update_flag = _manager.Value("b", False)
else: else:
new_update_flag = False # Create a simple mutable object to store boolean value for compatibility with mutiprocess
class MutableBoolean:
def __init__(self, initial_value=False):
self.value = initial_value
new_update_flag = MutableBoolean(False)
_update_flags[namespace].append(new_update_flag) _update_flags[namespace].append(new_update_flag)
return new_update_flag return new_update_flag
@@ -220,7 +348,26 @@ async def set_all_update_flags(namespace: str):
if is_multiprocess: if is_multiprocess:
_update_flags[namespace][i].value = True _update_flags[namespace][i].value = True
else: else:
_update_flags[namespace][i] = True # Use .value attribute instead of direct assignment
_update_flags[namespace][i].value = True
async def clear_all_update_flags(namespace: str):
"""Clear all update flag of namespace indicating all workers need to reload data from files"""
global _update_flags
if _update_flags is None:
raise ValueError("Try to create namespace before Shared-Data is initialized")
async with get_internal_lock():
if namespace not in _update_flags:
raise ValueError(f"Namespace {namespace} not found in update flags")
# Update flags for both modes
for i in range(len(_update_flags[namespace])):
if is_multiprocess:
_update_flags[namespace][i].value = False
else:
# Use .value attribute instead of direct assignment
_update_flags[namespace][i].value = False
async def get_all_update_flags_status() -> Dict[str, list]: async def get_all_update_flags_status() -> Dict[str, list]:
@@ -247,7 +394,7 @@ async def get_all_update_flags_status() -> Dict[str, list]:
return result return result
def try_initialize_namespace(namespace: str) -> bool: async def try_initialize_namespace(namespace: str) -> bool:
""" """
Returns True if the current worker(process) gets initialization permission for loading data later. Returns True if the current worker(process) gets initialization permission for loading data later.
The worker does not get the permission is prohibited to load data from files. The worker does not get the permission is prohibited to load data from files.
@@ -257,6 +404,7 @@ def try_initialize_namespace(namespace: str) -> bool:
if _init_flags is None: if _init_flags is None:
raise ValueError("Try to create nanmespace before Shared-Data is initialized") raise ValueError("Try to create nanmespace before Shared-Data is initialized")
async with get_internal_lock():
if namespace not in _init_flags: if namespace not in _init_flags:
_init_flags[namespace] = True _init_flags[namespace] = True
direct_log( direct_log(
@@ -266,6 +414,7 @@ def try_initialize_namespace(namespace: str) -> bool:
direct_log( direct_log(
f"Process {os.getpid()} storage namespace already initialized: [{namespace}]" f"Process {os.getpid()} storage namespace already initialized: [{namespace}]"
) )
return False return False
@@ -304,6 +453,8 @@ def finalize_share_data():
_storage_lock, \ _storage_lock, \
_internal_lock, \ _internal_lock, \
_pipeline_status_lock, \ _pipeline_status_lock, \
_graph_db_lock, \
_data_init_lock, \
_shared_dicts, \ _shared_dicts, \
_init_flags, \ _init_flags, \
_initialized, \ _initialized, \
@@ -369,6 +520,8 @@ def finalize_share_data():
_storage_lock = None _storage_lock = None
_internal_lock = None _internal_lock = None
_pipeline_status_lock = None _pipeline_status_lock = None
_graph_db_lock = None
_data_init_lock = None
_update_flags = None _update_flags = None
direct_log(f"Process {os.getpid()} storage data finalization complete") direct_log(f"Process {os.getpid()} storage data finalization complete")

View File

@@ -465,6 +465,100 @@ class TiDBVectorDBStorage(BaseVectorStorage):
logger.error(f"Error searching records with prefix '{prefix}': {e}") logger.error(f"Error searching records with prefix '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Determine which table to query based on namespace
if self.namespace == NameSpace.VECTOR_STORE_ENTITIES:
sql_template = """
SELECT entity_id as id, name as entity_name, entity_type, description, content
FROM LIGHTRAG_GRAPH_NODES
WHERE entity_id = :entity_id AND workspace = :workspace
"""
params = {"entity_id": id, "workspace": self.db.workspace}
elif self.namespace == NameSpace.VECTOR_STORE_RELATIONSHIPS:
sql_template = """
SELECT relation_id as id, source_name as src_id, target_name as tgt_id,
keywords, description, content
FROM LIGHTRAG_GRAPH_EDGES
WHERE relation_id = :relation_id AND workspace = :workspace
"""
params = {"relation_id": id, "workspace": self.db.workspace}
elif self.namespace == NameSpace.VECTOR_STORE_CHUNKS:
sql_template = """
SELECT chunk_id as id, content, tokens, chunk_order_index, full_doc_id
FROM LIGHTRAG_DOC_CHUNKS
WHERE chunk_id = :chunk_id AND workspace = :workspace
"""
params = {"chunk_id": id, "workspace": self.db.workspace}
else:
logger.warning(
f"Namespace {self.namespace} not supported for get_by_id"
)
return None
result = await self.db.query(sql_template, params=params)
return result
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Format IDs for SQL IN clause
ids_str = ", ".join([f"'{id}'" for id in ids])
# Determine which table to query based on namespace
if self.namespace == NameSpace.VECTOR_STORE_ENTITIES:
sql_template = f"""
SELECT entity_id as id, name as entity_name, entity_type, description, content
FROM LIGHTRAG_GRAPH_NODES
WHERE entity_id IN ({ids_str}) AND workspace = :workspace
"""
elif self.namespace == NameSpace.VECTOR_STORE_RELATIONSHIPS:
sql_template = f"""
SELECT relation_id as id, source_name as src_id, target_name as tgt_id,
keywords, description, content
FROM LIGHTRAG_GRAPH_EDGES
WHERE relation_id IN ({ids_str}) AND workspace = :workspace
"""
elif self.namespace == NameSpace.VECTOR_STORE_CHUNKS:
sql_template = f"""
SELECT chunk_id as id, content, tokens, chunk_order_index, full_doc_id
FROM LIGHTRAG_DOC_CHUNKS
WHERE chunk_id IN ({ids_str}) AND workspace = :workspace
"""
else:
logger.warning(
f"Namespace {self.namespace} not supported for get_by_ids"
)
return []
params = {"workspace": self.db.workspace}
results = await self.db.query(sql_template, params=params, multirows=True)
return results if results else []
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
@final @final
@dataclass @dataclass

View File

@@ -30,11 +30,10 @@ from .namespace import NameSpace, make_namespace
from .operate import ( from .operate import (
chunking_by_token_size, chunking_by_token_size,
extract_entities, extract_entities,
extract_keywords_only,
kg_query, kg_query,
kg_query_with_keywords,
mix_kg_vector_query, mix_kg_vector_query,
naive_query, naive_query,
query_with_keywords,
) )
from .prompt import GRAPH_FIELD_SEP, PROMPTS from .prompt import GRAPH_FIELD_SEP, PROMPTS
from .utils import ( from .utils import (
@@ -45,6 +44,9 @@ from .utils import (
encode_string_by_tiktoken, encode_string_by_tiktoken,
lazy_external_import, lazy_external_import,
limit_async_func_call, limit_async_func_call,
get_content_summary,
clean_text,
check_storage_env_vars,
logger, logger,
) )
from .types import KnowledgeGraph from .types import KnowledgeGraph
@@ -309,7 +311,7 @@ class LightRAG:
# Verify storage implementation compatibility # Verify storage implementation compatibility
verify_storage_implementation(storage_type, storage_name) verify_storage_implementation(storage_type, storage_name)
# Check environment variables # Check environment variables
# self.check_storage_env_vars(storage_name) check_storage_env_vars(storage_name)
# Ensure vector_db_storage_cls_kwargs has required fields # Ensure vector_db_storage_cls_kwargs has required fields
self.vector_db_storage_cls_kwargs = { self.vector_db_storage_cls_kwargs = {
@@ -354,6 +356,9 @@ class LightRAG:
namespace=make_namespace( namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
), ),
global_config=asdict(
self
), # Add global_config to ensure cache works properly
embedding_func=self.embedding_func, embedding_func=self.embedding_func,
) )
@@ -404,18 +409,8 @@ class LightRAG:
embedding_func=None, embedding_func=None,
) )
if self.llm_response_cache and hasattr( # Directly use llm_response_cache, don't create a new object
self.llm_response_cache, "global_config"
):
hashing_kv = self.llm_response_cache hashing_kv = self.llm_response_cache
else:
hashing_kv = self.key_string_value_json_storage_cls( # type: ignore
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
)
self.llm_model_func = limit_async_func_call(self.llm_model_max_async)( self.llm_model_func = limit_async_func_call(self.llm_model_max_async)(
partial( partial(
@@ -543,11 +538,6 @@ class LightRAG:
storage_class = lazy_external_import(import_path, storage_name) storage_class = lazy_external_import(import_path, storage_name)
return storage_class return storage_class
@staticmethod
def clean_text(text: str) -> str:
"""Clean text by removing null bytes (0x00) and whitespace"""
return text.strip().replace("\x00", "")
def insert( def insert(
self, self,
input: str | list[str], input: str | list[str],
@@ -590,6 +580,7 @@ class LightRAG:
split_by_character, split_by_character_only split_by_character, split_by_character_only
) )
# TODO: deprecated, use insert instead
def insert_custom_chunks( def insert_custom_chunks(
self, self,
full_text: str, full_text: str,
@@ -601,14 +592,15 @@ class LightRAG:
self.ainsert_custom_chunks(full_text, text_chunks, doc_id) self.ainsert_custom_chunks(full_text, text_chunks, doc_id)
) )
# TODO: deprecated, use ainsert instead
async def ainsert_custom_chunks( async def ainsert_custom_chunks(
self, full_text: str, text_chunks: list[str], doc_id: str | None = None self, full_text: str, text_chunks: list[str], doc_id: str | None = None
) -> None: ) -> None:
update_storage = False update_storage = False
try: try:
# Clean input texts # Clean input texts
full_text = self.clean_text(full_text) full_text = clean_text(full_text)
text_chunks = [self.clean_text(chunk) for chunk in text_chunks] text_chunks = [clean_text(chunk) for chunk in text_chunks]
# Process cleaned texts # Process cleaned texts
if doc_id is None: if doc_id is None:
@@ -687,7 +679,7 @@ class LightRAG:
contents = {id_: doc for id_, doc in zip(ids, input)} contents = {id_: doc for id_, doc in zip(ids, input)}
else: else:
# Clean input text and remove duplicates # Clean input text and remove duplicates
input = list(set(self.clean_text(doc) for doc in input)) input = list(set(clean_text(doc) for doc in input))
# Generate contents dict of MD5 hash IDs and documents # Generate contents dict of MD5 hash IDs and documents
contents = {compute_mdhash_id(doc, prefix="doc-"): doc for doc in input} contents = {compute_mdhash_id(doc, prefix="doc-"): doc for doc in input}
@@ -703,7 +695,7 @@ class LightRAG:
new_docs: dict[str, Any] = { new_docs: dict[str, Any] = {
id_: { id_: {
"content": content, "content": content,
"content_summary": self._get_content_summary(content), "content_summary": get_content_summary(content),
"content_length": len(content), "content_length": len(content),
"status": DocStatus.PENDING, "status": DocStatus.PENDING,
"created_at": datetime.now().isoformat(), "created_at": datetime.now().isoformat(),
@@ -892,7 +884,9 @@ class LightRAG:
self.chunks_vdb.upsert(chunks) self.chunks_vdb.upsert(chunks)
) )
entity_relation_task = asyncio.create_task( entity_relation_task = asyncio.create_task(
self._process_entity_relation_graph(chunks) self._process_entity_relation_graph(
chunks, pipeline_status, pipeline_status_lock
)
) )
full_docs_task = asyncio.create_task( full_docs_task = asyncio.create_task(
self.full_docs.upsert( self.full_docs.upsert(
@@ -1007,21 +1001,27 @@ class LightRAG:
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
async def _process_entity_relation_graph(self, chunk: dict[str, Any]) -> None: async def _process_entity_relation_graph(
self, chunk: dict[str, Any], pipeline_status=None, pipeline_status_lock=None
) -> None:
try: try:
await extract_entities( await extract_entities(
chunk, chunk,
knowledge_graph_inst=self.chunk_entity_relation_graph, knowledge_graph_inst=self.chunk_entity_relation_graph,
entity_vdb=self.entities_vdb, entity_vdb=self.entities_vdb,
relationships_vdb=self.relationships_vdb, relationships_vdb=self.relationships_vdb,
llm_response_cache=self.llm_response_cache,
global_config=asdict(self), global_config=asdict(self),
pipeline_status=pipeline_status,
pipeline_status_lock=pipeline_status_lock,
llm_response_cache=self.llm_response_cache,
) )
except Exception as e: except Exception as e:
logger.error("Failed to extract entities and relationships") logger.error("Failed to extract entities and relationships")
raise e raise e
async def _insert_done(self) -> None: async def _insert_done(
self, pipeline_status=None, pipeline_status_lock=None
) -> None:
tasks = [ tasks = [
cast(StorageNameSpace, storage_inst).index_done_callback() cast(StorageNameSpace, storage_inst).index_done_callback()
for storage_inst in [ # type: ignore for storage_inst in [ # type: ignore
@@ -1040,10 +1040,8 @@ class LightRAG:
log_message = "All Insert done" log_message = "All Insert done"
logger.info(log_message) logger.info(log_message)
# 获取 pipeline_status 并更新 latest_message 和 history_messages if pipeline_status is not None and pipeline_status_lock is not None:
from lightrag.kg.shared_storage import get_namespace_data async with pipeline_status_lock:
pipeline_status = await get_namespace_data("pipeline_status")
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
@@ -1062,7 +1060,7 @@ class LightRAG:
all_chunks_data: dict[str, dict[str, str]] = {} all_chunks_data: dict[str, dict[str, str]] = {}
chunk_to_source_map: dict[str, str] = {} chunk_to_source_map: dict[str, str] = {}
for chunk_data in custom_kg.get("chunks", []): for chunk_data in custom_kg.get("chunks", []):
chunk_content = self.clean_text(chunk_data["content"]) chunk_content = clean_text(chunk_data["content"])
source_id = chunk_data["source_id"] source_id = chunk_data["source_id"]
tokens = len( tokens = len(
encode_string_by_tiktoken( encode_string_by_tiktoken(
@@ -1260,16 +1258,7 @@ class LightRAG:
self.text_chunks, self.text_chunks,
param, param,
asdict(self), asdict(self),
hashing_kv=self.llm_response_cache hashing_kv=self.llm_response_cache, # Directly use llm_response_cache
if self.llm_response_cache
and hasattr(self.llm_response_cache, "global_config")
else self.key_string_value_json_storage_cls(
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
),
system_prompt=system_prompt, system_prompt=system_prompt,
) )
elif param.mode == "naive": elif param.mode == "naive":
@@ -1279,16 +1268,7 @@ class LightRAG:
self.text_chunks, self.text_chunks,
param, param,
asdict(self), asdict(self),
hashing_kv=self.llm_response_cache hashing_kv=self.llm_response_cache, # Directly use llm_response_cache
if self.llm_response_cache
and hasattr(self.llm_response_cache, "global_config")
else self.key_string_value_json_storage_cls(
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
),
system_prompt=system_prompt, system_prompt=system_prompt,
) )
elif param.mode == "mix": elif param.mode == "mix":
@@ -1301,16 +1281,7 @@ class LightRAG:
self.text_chunks, self.text_chunks,
param, param,
asdict(self), asdict(self),
hashing_kv=self.llm_response_cache hashing_kv=self.llm_response_cache, # Directly use llm_response_cache
if self.llm_response_cache
and hasattr(self.llm_response_cache, "global_config")
else self.key_string_value_json_storage_cls(
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
),
system_prompt=system_prompt, system_prompt=system_prompt,
) )
else: else:
@@ -1322,8 +1293,17 @@ class LightRAG:
self, query: str, prompt: str, param: QueryParam = QueryParam() self, query: str, prompt: str, param: QueryParam = QueryParam()
): ):
""" """
1. Extract keywords from the 'query' using new function in operate.py. Query with separate keyword extraction step.
2. Then run the standard aquery() flow with the final prompt (formatted_question).
This method extracts keywords from the query first, then uses them for the query.
Args:
query: User query
prompt: Additional prompt for the query
param: Query parameters
Returns:
Query response
""" """
loop = always_get_an_event_loop() loop = always_get_an_event_loop()
return loop.run_until_complete( return loop.run_until_complete(
@@ -1334,100 +1314,29 @@ class LightRAG:
self, query: str, prompt: str, param: QueryParam = QueryParam() self, query: str, prompt: str, param: QueryParam = QueryParam()
) -> str | AsyncIterator[str]: ) -> str | AsyncIterator[str]:
""" """
1. Calls extract_keywords_only to get HL/LL keywords from 'query'. Async version of query_with_separate_keyword_extraction.
2. Then calls kg_query(...) or naive_query(...), etc. as the main query, while also injecting the newly extracted keywords if needed.
Args:
query: User query
prompt: Additional prompt for the query
param: Query parameters
Returns:
Query response or async iterator
""" """
# --------------------- response = await query_with_keywords(
# STEP 1: Keyword Extraction query=query,
# --------------------- prompt=prompt,
hl_keywords, ll_keywords = await extract_keywords_only(
text=query,
param=param, param=param,
knowledge_graph_inst=self.chunk_entity_relation_graph,
entities_vdb=self.entities_vdb,
relationships_vdb=self.relationships_vdb,
chunks_vdb=self.chunks_vdb,
text_chunks_db=self.text_chunks,
global_config=asdict(self), global_config=asdict(self),
hashing_kv=self.llm_response_cache hashing_kv=self.llm_response_cache,
or self.key_string_value_json_storage_cls(
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
),
) )
param.hl_keywords = hl_keywords
param.ll_keywords = ll_keywords
# ---------------------
# STEP 2: Final Query Logic
# ---------------------
# Create a new string with the prompt and the keywords
ll_keywords_str = ", ".join(ll_keywords)
hl_keywords_str = ", ".join(hl_keywords)
formatted_question = f"{prompt}\n\n### Keywords:\nHigh-level: {hl_keywords_str}\nLow-level: {ll_keywords_str}\n\n### Query:\n{query}"
if param.mode in ["local", "global", "hybrid"]:
response = await kg_query_with_keywords(
formatted_question,
self.chunk_entity_relation_graph,
self.entities_vdb,
self.relationships_vdb,
self.text_chunks,
param,
asdict(self),
hashing_kv=self.llm_response_cache
if self.llm_response_cache
and hasattr(self.llm_response_cache, "global_config")
else self.key_string_value_json_storage_cls(
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
),
)
elif param.mode == "naive":
response = await naive_query(
formatted_question,
self.chunks_vdb,
self.text_chunks,
param,
asdict(self),
hashing_kv=self.llm_response_cache
if self.llm_response_cache
and hasattr(self.llm_response_cache, "global_config")
else self.key_string_value_json_storage_cls(
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
),
)
elif param.mode == "mix":
response = await mix_kg_vector_query(
formatted_question,
self.chunk_entity_relation_graph,
self.entities_vdb,
self.relationships_vdb,
self.chunks_vdb,
self.text_chunks,
param,
asdict(self),
hashing_kv=self.llm_response_cache
if self.llm_response_cache
and hasattr(self.llm_response_cache, "global_config")
else self.key_string_value_json_storage_cls(
namespace=make_namespace(
self.namespace_prefix, NameSpace.KV_STORE_LLM_RESPONSE_CACHE
),
global_config=asdict(self),
embedding_func=self.embedding_func,
),
)
else:
raise ValueError(f"Unknown mode {param.mode}")
await self._query_done() await self._query_done()
return response return response
@@ -1525,21 +1434,6 @@ class LightRAG:
] ]
) )
def _get_content_summary(self, content: str, max_length: int = 100) -> str:
"""Get summary of document content
Args:
content: Original document content
max_length: Maximum length of summary
Returns:
Truncated content with ellipsis if needed
"""
content = content.strip()
if len(content) <= max_length:
return content
return content[:max_length] + "..."
async def get_processing_status(self) -> dict[str, int]: async def get_processing_status(self) -> dict[str, int]:
"""Get current document processing status counts """Get current document processing status counts
@@ -1816,19 +1710,7 @@ class LightRAG:
async def get_entity_info( async def get_entity_info(
self, entity_name: str, include_vector_data: bool = False self, entity_name: str, include_vector_data: bool = False
) -> dict[str, str | None | dict[str, str]]: ) -> dict[str, str | None | dict[str, str]]:
"""Get detailed information of an entity """Get detailed information of an entity"""
Args:
entity_name: Entity name (no need for quotes)
include_vector_data: Whether to include data from the vector database
Returns:
dict: A dictionary containing entity information, including:
- entity_name: Entity name
- source_id: Source document ID
- graph_data: Complete node data from the graph database
- vector_data: (optional) Data from the vector database
"""
# Get information from the graph # Get information from the graph
node_data = await self.chunk_entity_relation_graph.get_node(entity_name) node_data = await self.chunk_entity_relation_graph.get_node(entity_name)
@@ -1843,29 +1725,15 @@ class LightRAG:
# Optional: Get vector database information # Optional: Get vector database information
if include_vector_data: if include_vector_data:
entity_id = compute_mdhash_id(entity_name, prefix="ent-") entity_id = compute_mdhash_id(entity_name, prefix="ent-")
vector_data = self.entities_vdb._client.get([entity_id]) vector_data = await self.entities_vdb.get_by_id(entity_id)
result["vector_data"] = vector_data[0] if vector_data else None result["vector_data"] = vector_data
return result return result
async def get_relation_info( async def get_relation_info(
self, src_entity: str, tgt_entity: str, include_vector_data: bool = False self, src_entity: str, tgt_entity: str, include_vector_data: bool = False
) -> dict[str, str | None | dict[str, str]]: ) -> dict[str, str | None | dict[str, str]]:
"""Get detailed information of a relationship """Get detailed information of a relationship"""
Args:
src_entity: Source entity name (no need for quotes)
tgt_entity: Target entity name (no need for quotes)
include_vector_data: Whether to include data from the vector database
Returns:
dict: A dictionary containing relationship information, including:
- src_entity: Source entity name
- tgt_entity: Target entity name
- source_id: Source document ID
- graph_data: Complete edge data from the graph database
- vector_data: (optional) Data from the vector database
"""
# Get information from the graph # Get information from the graph
edge_data = await self.chunk_entity_relation_graph.get_edge( edge_data = await self.chunk_entity_relation_graph.get_edge(
@@ -1883,8 +1751,8 @@ class LightRAG:
# Optional: Get vector database information # Optional: Get vector database information
if include_vector_data: if include_vector_data:
rel_id = compute_mdhash_id(src_entity + tgt_entity, prefix="rel-") rel_id = compute_mdhash_id(src_entity + tgt_entity, prefix="rel-")
vector_data = self.relationships_vdb._client.get([rel_id]) vector_data = await self.relationships_vdb.get_by_id(rel_id)
result["vector_data"] = vector_data[0] if vector_data else None result["vector_data"] = vector_data
return result return result
@@ -2682,6 +2550,12 @@ class LightRAG:
# 9. Delete source entities # 9. Delete source entities
for entity_name in source_entities: for entity_name in source_entities:
if entity_name == target_entity:
logger.info(
f"Skipping deletion of '{entity_name}' as it's also the target entity"
)
continue
# Delete entity node from knowledge graph # Delete entity node from knowledge graph
await self.chunk_entity_relation_graph.delete_node(entity_name) await self.chunk_entity_relation_graph.delete_node(entity_name)

View File

@@ -55,6 +55,7 @@ async def azure_openai_complete_if_cache(
openai_async_client = AsyncAzureOpenAI( openai_async_client = AsyncAzureOpenAI(
azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
azure_deployment=model,
api_key=os.getenv("AZURE_OPENAI_API_KEY"), api_key=os.getenv("AZURE_OPENAI_API_KEY"),
api_version=os.getenv("AZURE_OPENAI_API_VERSION"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
) )
@@ -136,6 +137,7 @@ async def azure_openai_embed(
openai_async_client = AsyncAzureOpenAI( openai_async_client = AsyncAzureOpenAI(
azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
azure_deployment=model,
api_key=os.getenv("AZURE_OPENAI_API_KEY"), api_key=os.getenv("AZURE_OPENAI_API_KEY"),
api_version=os.getenv("AZURE_OPENAI_API_VERSION"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
) )

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re import re
import os
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -140,18 +141,36 @@ async def _handle_single_entity_extraction(
): ):
if len(record_attributes) < 4 or record_attributes[0] != '"entity"': if len(record_attributes) < 4 or record_attributes[0] != '"entity"':
return None return None
# add this record as a node in the G
# Clean and validate entity name
entity_name = clean_str(record_attributes[1]).strip('"') entity_name = clean_str(record_attributes[1]).strip('"')
if not entity_name.strip(): if not entity_name.strip():
logger.warning(
f"Entity extraction error: empty entity name in: {record_attributes}"
)
return None return None
# Clean and validate entity type
entity_type = clean_str(record_attributes[2]).strip('"') entity_type = clean_str(record_attributes[2]).strip('"')
if not entity_type.strip() or entity_type.startswith('("'):
logger.warning(
f"Entity extraction error: invalid entity type in: {record_attributes}"
)
return None
# Clean and validate description
entity_description = clean_str(record_attributes[3]).strip('"') entity_description = clean_str(record_attributes[3]).strip('"')
entity_source_id = chunk_key if not entity_description.strip():
logger.warning(
f"Entity extraction error: empty description for entity '{entity_name}' of type '{entity_type}'"
)
return None
return dict( return dict(
entity_name=entity_name, entity_name=entity_name,
entity_type=entity_type, entity_type=entity_type,
description=entity_description, description=entity_description,
source_id=entity_source_id, source_id=chunk_key,
metadata={"created_at": time.time()}, metadata={"created_at": time.time()},
) )
@@ -220,6 +239,7 @@ async def _merge_nodes_then_upsert(
entity_name, description, global_config entity_name, description, global_config
) )
node_data = dict( node_data = dict(
entity_id=entity_name,
entity_type=entity_type, entity_type=entity_type,
description=description, description=description,
source_id=source_id, source_id=source_id,
@@ -301,6 +321,7 @@ async def _merge_edges_then_upsert(
await knowledge_graph_inst.upsert_node( await knowledge_graph_inst.upsert_node(
need_insert_id, need_insert_id,
node_data={ node_data={
"entity_id": need_insert_id,
"source_id": source_id, "source_id": source_id,
"description": description, "description": description,
"entity_type": "UNKNOWN", "entity_type": "UNKNOWN",
@@ -337,11 +358,10 @@ async def extract_entities(
entity_vdb: BaseVectorStorage, entity_vdb: BaseVectorStorage,
relationships_vdb: BaseVectorStorage, relationships_vdb: BaseVectorStorage,
global_config: dict[str, str], global_config: dict[str, str],
pipeline_status: dict = None,
pipeline_status_lock=None,
llm_response_cache: BaseKVStorage | None = None, llm_response_cache: BaseKVStorage | None = None,
) -> None: ) -> None:
from lightrag.kg.shared_storage import get_namespace_data
pipeline_status = await get_namespace_data("pipeline_status")
use_llm_func: callable = global_config["llm_model_func"] use_llm_func: callable = global_config["llm_model_func"]
entity_extract_max_gleaning = global_config["entity_extract_max_gleaning"] entity_extract_max_gleaning = global_config["entity_extract_max_gleaning"]
enable_llm_cache_for_entity_extract: bool = global_config[ enable_llm_cache_for_entity_extract: bool = global_config[
@@ -400,6 +420,7 @@ async def extract_entities(
else: else:
_prompt = input_text _prompt = input_text
# TODO add cache_type="extract"
arg_hash = compute_args_hash(_prompt) arg_hash = compute_args_hash(_prompt)
cached_return, _1, _2, _3 = await handle_cache( cached_return, _1, _2, _3 = await handle_cache(
llm_response_cache, llm_response_cache,
@@ -407,7 +428,6 @@ async def extract_entities(
_prompt, _prompt,
"default", "default",
cache_type="extract", cache_type="extract",
force_llm_cache=True,
) )
if cached_return: if cached_return:
logger.debug(f"Found cache for {arg_hash}") logger.debug(f"Found cache for {arg_hash}")
@@ -436,47 +456,22 @@ async def extract_entities(
else: else:
return await use_llm_func(input_text) return await use_llm_func(input_text)
async def _process_single_content(chunk_key_dp: tuple[str, TextChunkSchema]): async def _process_extraction_result(result: str, chunk_key: str):
""" "Prpocess a single chunk """Process a single extraction result (either initial or gleaning)
Args: Args:
chunk_key_dp (tuple[str, TextChunkSchema]): result (str): The extraction result to process
("chunck-xxxxxx", {"tokens": int, "content": str, "full_doc_id": str, "chunk_order_index": int}) chunk_key (str): The chunk key for source tracking
Returns:
tuple: (nodes_dict, edges_dict) containing the extracted entities and relationships
""" """
nonlocal processed_chunks maybe_nodes = defaultdict(list)
chunk_key = chunk_key_dp[0] maybe_edges = defaultdict(list)
chunk_dp = chunk_key_dp[1]
content = chunk_dp["content"]
# hint_prompt = entity_extract_prompt.format(**context_base, input_text=content)
hint_prompt = entity_extract_prompt.format(
**context_base, input_text="{input_text}"
).format(**context_base, input_text=content)
final_result = await _user_llm_func_with_cache(hint_prompt)
history = pack_user_ass_to_openai_messages(hint_prompt, final_result)
for now_glean_index in range(entity_extract_max_gleaning):
glean_result = await _user_llm_func_with_cache(
continue_prompt, history_messages=history
)
history += pack_user_ass_to_openai_messages(continue_prompt, glean_result)
final_result += glean_result
if now_glean_index == entity_extract_max_gleaning - 1:
break
if_loop_result: str = await _user_llm_func_with_cache(
if_loop_prompt, history_messages=history
)
if_loop_result = if_loop_result.strip().strip('"').strip("'").lower()
if if_loop_result != "yes":
break
records = split_string_by_multi_markers( records = split_string_by_multi_markers(
final_result, result,
[context_base["record_delimiter"], context_base["completion_delimiter"]], [context_base["record_delimiter"], context_base["completion_delimiter"]],
) )
maybe_nodes = defaultdict(list)
maybe_edges = defaultdict(list)
for record in records: for record in records:
record = re.search(r"\((.*)\)", record) record = re.search(r"\((.*)\)", record)
if record is None: if record is None:
@@ -485,6 +480,7 @@ async def extract_entities(
record_attributes = split_string_by_multi_markers( record_attributes = split_string_by_multi_markers(
record, [context_base["tuple_delimiter"]] record, [context_base["tuple_delimiter"]]
) )
if_entities = await _handle_single_entity_extraction( if_entities = await _handle_single_entity_extraction(
record_attributes, chunk_key record_attributes, chunk_key
) )
@@ -499,11 +495,69 @@ async def extract_entities(
maybe_edges[(if_relation["src_id"], if_relation["tgt_id"])].append( maybe_edges[(if_relation["src_id"], if_relation["tgt_id"])].append(
if_relation if_relation
) )
return maybe_nodes, maybe_edges
async def _process_single_content(chunk_key_dp: tuple[str, TextChunkSchema]):
"""Process a single chunk
Args:
chunk_key_dp (tuple[str, TextChunkSchema]):
("chunk-xxxxxx", {"tokens": int, "content": str, "full_doc_id": str, "chunk_order_index": int})
"""
nonlocal processed_chunks
chunk_key = chunk_key_dp[0]
chunk_dp = chunk_key_dp[1]
content = chunk_dp["content"]
# Get initial extraction
hint_prompt = entity_extract_prompt.format(
**context_base, input_text="{input_text}"
).format(**context_base, input_text=content)
final_result = await _user_llm_func_with_cache(hint_prompt)
history = pack_user_ass_to_openai_messages(hint_prompt, final_result)
# Process initial extraction
maybe_nodes, maybe_edges = await _process_extraction_result(
final_result, chunk_key
)
# Process additional gleaning results
for now_glean_index in range(entity_extract_max_gleaning):
glean_result = await _user_llm_func_with_cache(
continue_prompt, history_messages=history
)
history += pack_user_ass_to_openai_messages(continue_prompt, glean_result)
# Process gleaning result separately
glean_nodes, glean_edges = await _process_extraction_result(
glean_result, chunk_key
)
# Merge results
for entity_name, entities in glean_nodes.items():
maybe_nodes[entity_name].extend(entities)
for edge_key, edges in glean_edges.items():
maybe_edges[edge_key].extend(edges)
if now_glean_index == entity_extract_max_gleaning - 1:
break
if_loop_result: str = await _user_llm_func_with_cache(
if_loop_prompt, history_messages=history
)
if_loop_result = if_loop_result.strip().strip('"').strip("'").lower()
if if_loop_result != "yes":
break
processed_chunks += 1 processed_chunks += 1
entities_count = len(maybe_nodes) entities_count = len(maybe_nodes)
relations_count = len(maybe_edges) relations_count = len(maybe_edges)
log_message = f" Chunk {processed_chunks}/{total_chunks}: extracted {entities_count} entities and {relations_count} relationships (deduplicated)" log_message = f" Chunk {processed_chunks}/{total_chunks}: extracted {entities_count} entities and {relations_count} relationships (deduplicated)"
logger.info(log_message) logger.info(log_message)
if pipeline_status is not None:
async with pipeline_status_lock:
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
return dict(maybe_nodes), dict(maybe_edges) return dict(maybe_nodes), dict(maybe_edges)
@@ -519,6 +573,12 @@ async def extract_entities(
for k, v in m_edges.items(): for k, v in m_edges.items():
maybe_edges[tuple(sorted(k))].extend(v) maybe_edges[tuple(sorted(k))].extend(v)
from .kg.shared_storage import get_graph_db_lock
graph_db_lock = get_graph_db_lock(enable_logging=False)
# Ensure that nodes and edges are merged and upserted atomically
async with graph_db_lock:
all_entities_data = await asyncio.gather( all_entities_data = await asyncio.gather(
*[ *[
_merge_nodes_then_upsert(k, v, knowledge_graph_inst, global_config) _merge_nodes_then_upsert(k, v, knowledge_graph_inst, global_config)
@@ -528,7 +588,9 @@ async def extract_entities(
all_relationships_data = await asyncio.gather( all_relationships_data = await asyncio.gather(
*[ *[
_merge_edges_then_upsert(k[0], k[1], v, knowledge_graph_inst, global_config) _merge_edges_then_upsert(
k[0], k[1], v, knowledge_graph_inst, global_config
)
for k, v in maybe_edges.items() for k, v in maybe_edges.items()
] ]
) )
@@ -536,6 +598,8 @@ async def extract_entities(
if not (all_entities_data or all_relationships_data): if not (all_entities_data or all_relationships_data):
log_message = "Didn't extract any entities and relationships." log_message = "Didn't extract any entities and relationships."
logger.info(log_message) logger.info(log_message)
if pipeline_status is not None:
async with pipeline_status_lock:
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
return return
@@ -543,16 +607,22 @@ async def extract_entities(
if not all_entities_data: if not all_entities_data:
log_message = "Didn't extract any entities" log_message = "Didn't extract any entities"
logger.info(log_message) logger.info(log_message)
if pipeline_status is not None:
async with pipeline_status_lock:
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
if not all_relationships_data: if not all_relationships_data:
log_message = "Didn't extract any relationships" log_message = "Didn't extract any relationships"
logger.info(log_message) logger.info(log_message)
if pipeline_status is not None:
async with pipeline_status_lock:
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
log_message = f"Extracted {len(all_entities_data)} entities and {len(all_relationships_data)} relationships (deduplicated)" log_message = f"Extracted {len(all_entities_data)} entities and {len(all_relationships_data)} relationships (deduplicated)"
logger.info(log_message) logger.info(log_message)
if pipeline_status is not None:
async with pipeline_status_lock:
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
verbose_debug( verbose_debug(
@@ -1020,6 +1090,7 @@ async def _build_query_context(
text_chunks_db: BaseKVStorage, text_chunks_db: BaseKVStorage,
query_param: QueryParam, query_param: QueryParam,
): ):
logger.info(f"Process {os.getpid()} buidling query context...")
if query_param.mode == "local": if query_param.mode == "local":
entities_context, relations_context, text_units_context = await _get_node_data( entities_context, relations_context, text_units_context = await _get_node_data(
ll_keywords, ll_keywords,
@@ -1845,3 +1916,90 @@ async def kg_query_with_keywords(
) )
return response return response
async def query_with_keywords(
query: str,
prompt: str,
param: QueryParam,
knowledge_graph_inst: BaseGraphStorage,
entities_vdb: BaseVectorStorage,
relationships_vdb: BaseVectorStorage,
chunks_vdb: BaseVectorStorage,
text_chunks_db: BaseKVStorage,
global_config: dict[str, str],
hashing_kv: BaseKVStorage | None = None,
) -> str | AsyncIterator[str]:
"""
Extract keywords from the query and then use them for retrieving information.
1. Extracts high-level and low-level keywords from the query
2. Formats the query with the extracted keywords and prompt
3. Uses the appropriate query method based on param.mode
Args:
query: The user's query
prompt: Additional prompt to prepend to the query
param: Query parameters
knowledge_graph_inst: Knowledge graph storage
entities_vdb: Entities vector database
relationships_vdb: Relationships vector database
chunks_vdb: Document chunks vector database
text_chunks_db: Text chunks storage
global_config: Global configuration
hashing_kv: Cache storage
Returns:
Query response or async iterator
"""
# Extract keywords
hl_keywords, ll_keywords = await extract_keywords_only(
text=query,
param=param,
global_config=global_config,
hashing_kv=hashing_kv,
)
param.hl_keywords = hl_keywords
param.ll_keywords = ll_keywords
# Create a new string with the prompt and the keywords
ll_keywords_str = ", ".join(ll_keywords)
hl_keywords_str = ", ".join(hl_keywords)
formatted_question = f"{prompt}\n\n### Keywords:\nHigh-level: {hl_keywords_str}\nLow-level: {ll_keywords_str}\n\n### Query:\n{query}"
# Use appropriate query method based on mode
if param.mode in ["local", "global", "hybrid"]:
return await kg_query_with_keywords(
formatted_question,
knowledge_graph_inst,
entities_vdb,
relationships_vdb,
text_chunks_db,
param,
global_config,
hashing_kv=hashing_kv,
)
elif param.mode == "naive":
return await naive_query(
formatted_question,
chunks_vdb,
text_chunks_db,
param,
global_config,
hashing_kv=hashing_kv,
)
elif param.mode == "mix":
return await mix_kg_vector_query(
formatted_question,
knowledge_graph_inst,
entities_vdb,
relationships_vdb,
chunks_vdb,
text_chunks_db,
param,
global_config,
hashing_kv=hashing_kv,
)
else:
raise ValueError(f"Unknown mode {param.mode}")

View File

@@ -236,7 +236,7 @@ Given the query and conversation history, list both high-level and low-level key
---Instructions--- ---Instructions---
- Consider both the current query and relevant conversation history when extracting keywords - Consider both the current query and relevant conversation history when extracting keywords
- Output the keywords in JSON format - Output the keywords in JSON format, it will be parsed by a JSON parser, do not add any extra content in output
- The JSON should have two keys: - The JSON should have two keys:
- "high_level_keywords" for overarching concepts or themes - "high_level_keywords" for overarching concepts or themes
- "low_level_keywords" for specific entities or details - "low_level_keywords" for specific entities or details

View File

@@ -633,15 +633,15 @@ async def handle_cache(
prompt, prompt,
mode="default", mode="default",
cache_type=None, cache_type=None,
force_llm_cache=False,
): ):
"""Generic cache handling function""" """Generic cache handling function"""
if hashing_kv is None or not ( if hashing_kv is None:
force_llm_cache or hashing_kv.global_config.get("enable_llm_cache") return None, None, None, None
):
if mode != "default": # handle cache for all type of query
if not hashing_kv.global_config.get("enable_llm_cache"):
return None, None, None, None return None, None, None, None
if mode != "default":
# Get embedding cache configuration # Get embedding cache configuration
embedding_cache_config = hashing_kv.global_config.get( embedding_cache_config = hashing_kv.global_config.get(
"embedding_cache_config", "embedding_cache_config",
@@ -651,8 +651,7 @@ async def handle_cache(
use_llm_check = embedding_cache_config.get("use_llm_check", False) use_llm_check = embedding_cache_config.get("use_llm_check", False)
quantized = min_val = max_val = None quantized = min_val = max_val = None
if is_embedding_cache_enabled: if is_embedding_cache_enabled: # Use embedding simularity to match cache
# Use embedding cache
current_embedding = await hashing_kv.embedding_func([prompt]) current_embedding = await hashing_kv.embedding_func([prompt])
llm_model_func = hashing_kv.global_config.get("llm_model_func") llm_model_func = hashing_kv.global_config.get("llm_model_func")
quantized, min_val, max_val = quantize_embedding(current_embedding[0]) quantized, min_val, max_val = quantize_embedding(current_embedding[0])
@@ -667,24 +666,29 @@ async def handle_cache(
cache_type=cache_type, cache_type=cache_type,
) )
if best_cached_response is not None: if best_cached_response is not None:
logger.info(f"Embedding cached hit(mode:{mode} type:{cache_type})") logger.debug(f"Embedding cached hit(mode:{mode} type:{cache_type})")
return best_cached_response, None, None, None return best_cached_response, None, None, None
else: else:
# if caching keyword embedding is enabled, return the quantized embedding for saving it latter # if caching keyword embedding is enabled, return the quantized embedding for saving it latter
logger.info(f"Embedding cached missed(mode:{mode} type:{cache_type})") logger.debug(f"Embedding cached missed(mode:{mode} type:{cache_type})")
return None, quantized, min_val, max_val return None, quantized, min_val, max_val
# For default mode or is_embedding_cache_enabled is False, use regular cache else: # handle cache for entity extraction
# default mode is for extract_entities or naive query if not hashing_kv.global_config.get("enable_llm_cache_for_entity_extract"):
return None, None, None, None
# Here is the conditions of code reaching this point:
# 1. All query mode: enable_llm_cache is True and embedding simularity is not enabled
# 2. Entity extract: enable_llm_cache_for_entity_extract is True
if exists_func(hashing_kv, "get_by_mode_and_id"): if exists_func(hashing_kv, "get_by_mode_and_id"):
mode_cache = await hashing_kv.get_by_mode_and_id(mode, args_hash) or {} mode_cache = await hashing_kv.get_by_mode_and_id(mode, args_hash) or {}
else: else:
mode_cache = await hashing_kv.get_by_id(mode) or {} mode_cache = await hashing_kv.get_by_id(mode) or {}
if args_hash in mode_cache: if args_hash in mode_cache:
logger.info(f"Non-embedding cached hit(mode:{mode} type:{cache_type})") logger.debug(f"Non-embedding cached hit(mode:{mode} type:{cache_type})")
return mode_cache[args_hash]["return"], None, None, None return mode_cache[args_hash]["return"], None, None, None
logger.info(f"Non-embedding cached missed(mode:{mode} type:{cache_type})") logger.debug(f"Non-embedding cached missed(mode:{mode} type:{cache_type})")
return None, None, None, None return None, None, None, None
@@ -701,9 +705,22 @@ class CacheData:
async def save_to_cache(hashing_kv, cache_data: CacheData): async def save_to_cache(hashing_kv, cache_data: CacheData):
if hashing_kv is None or hasattr(cache_data.content, "__aiter__"): """Save data to cache, with improved handling for streaming responses and duplicate content.
Args:
hashing_kv: The key-value storage for caching
cache_data: The cache data to save
"""
# Skip if storage is None or content is a streaming response
if hashing_kv is None or not cache_data.content:
return return
# If content is a streaming response, don't cache it
if hasattr(cache_data.content, "__aiter__"):
logger.debug("Streaming response detected, skipping cache")
return
# Get existing cache data
if exists_func(hashing_kv, "get_by_mode_and_id"): if exists_func(hashing_kv, "get_by_mode_and_id"):
mode_cache = ( mode_cache = (
await hashing_kv.get_by_mode_and_id(cache_data.mode, cache_data.args_hash) await hashing_kv.get_by_mode_and_id(cache_data.mode, cache_data.args_hash)
@@ -712,6 +729,16 @@ async def save_to_cache(hashing_kv, cache_data: CacheData):
else: else:
mode_cache = await hashing_kv.get_by_id(cache_data.mode) or {} mode_cache = await hashing_kv.get_by_id(cache_data.mode) or {}
# Check if we already have identical content cached
if cache_data.args_hash in mode_cache:
existing_content = mode_cache[cache_data.args_hash].get("return")
if existing_content == cache_data.content:
logger.info(
f"Cache content unchanged for {cache_data.args_hash}, skipping update"
)
return
# Update cache with new content
mode_cache[cache_data.args_hash] = { mode_cache[cache_data.args_hash] = {
"return": cache_data.content, "return": cache_data.content,
"cache_type": cache_data.cache_type, "cache_type": cache_data.cache_type,
@@ -726,6 +753,7 @@ async def save_to_cache(hashing_kv, cache_data: CacheData):
"original_prompt": cache_data.prompt, "original_prompt": cache_data.prompt,
} }
# Only upsert if there's actual new content
await hashing_kv.upsert({cache_data.mode: mode_cache}) await hashing_kv.upsert({cache_data.mode: mode_cache})
@@ -862,3 +890,52 @@ def lazy_external_import(module_name: str, class_name: str) -> Callable[..., Any
return cls(*args, **kwargs) return cls(*args, **kwargs)
return import_class return import_class
def get_content_summary(content: str, max_length: int = 100) -> str:
"""Get summary of document content
Args:
content: Original document content
max_length: Maximum length of summary
Returns:
Truncated content with ellipsis if needed
"""
content = content.strip()
if len(content) <= max_length:
return content
return content[:max_length] + "..."
def clean_text(text: str) -> str:
"""Clean text by removing null bytes (0x00) and whitespace
Args:
text: Input text to clean
Returns:
Cleaned text
"""
return text.strip().replace("\x00", "")
def check_storage_env_vars(storage_name: str) -> None:
"""Check if all required environment variables for storage implementation exist
Args:
storage_name: Storage implementation name
Raises:
ValueError: If required environment variables are missing
"""
from lightrag.kg import STORAGE_ENV_REQUIREMENTS
required_vars = STORAGE_ENV_REQUIREMENTS.get(storage_name, [])
missing_vars = [var for var in required_vars if var not in os.environ]
if missing_vars:
raise ValueError(
f"Storage implementation '{storage_name}' requires the following "
f"environment variables: {', '.join(missing_vars)}"
)

View File

@@ -34,11 +34,13 @@
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"graphology-generators": "^0.11.2", "graphology-generators": "^0.11.2",
"i18next": "^24.2.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"minisearch": "^7.1.2", "minisearch": "^7.1.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.6", "react-dropzone": "^14.3.6",
"react-i18next": "^15.4.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
@@ -765,8 +767,12 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@24.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -1093,6 +1099,8 @@
"react-dropzone": ["react-dropzone@14.3.6", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-U792j+x0rcwH/U/Slv/OBNU/LGFYbDLHKKiJoPhNaOianayZevCt4Y5S0CraPssH/6/wT6xhKDfzdXUgCBS0HQ=="], "react-dropzone": ["react-dropzone@14.3.6", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-U792j+x0rcwH/U/Slv/OBNU/LGFYbDLHKKiJoPhNaOianayZevCt4Y5S0CraPssH/6/wT6xhKDfzdXUgCBS0HQ=="],
"react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="], "react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
@@ -1271,6 +1279,8 @@
"vite": ["vite@6.1.1", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.2", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA=="], "vite": ["vite@6.1.1", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.2", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],

View File

@@ -43,11 +43,13 @@
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"graphology-generators": "^0.11.2", "graphology-generators": "^0.11.2",
"i18next": "^24.2.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"minisearch": "^7.1.2", "minisearch": "^7.1.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.6", "react-dropzone": "^14.3.6",
"react-i18next": "^15.4.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",

View File

@@ -3,6 +3,7 @@ import useTheme from '@/hooks/useTheme'
import { MoonIcon, SunIcon } from 'lucide-react' import { MoonIcon, SunIcon } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
/** /**
* Component that toggles the theme between light and dark. * Component that toggles the theme between light and dark.
@@ -11,13 +12,14 @@ export default function ThemeToggle() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const setLight = useCallback(() => setTheme('light'), [setTheme]) const setLight = useCallback(() => setTheme('light'), [setTheme])
const setDark = useCallback(() => setTheme('dark'), [setTheme]) const setDark = useCallback(() => setTheme('dark'), [setTheme])
const { t } = useTranslation()
if (theme === 'dark') { if (theme === 'dark') {
return ( return (
<Button <Button
onClick={setLight} onClick={setLight}
variant={controlButtonVariant} variant={controlButtonVariant}
tooltip="Switch to light theme" tooltip={t('header.themeToggle.switchToLight')}
size="icon" size="icon"
side="bottom" side="bottom"
> >
@@ -29,7 +31,7 @@ export default function ThemeToggle() {
<Button <Button
onClick={setDark} onClick={setDark}
variant={controlButtonVariant} variant={controlButtonVariant}
tooltip="Switch to dark theme" tooltip={t('header.themeToggle.switchToDark')}
size="icon" size="icon"
side="bottom" side="bottom"
> >

View File

@@ -13,38 +13,40 @@ import { errorMessage } from '@/lib/utils'
import { clearDocuments } from '@/api/lightrag' import { clearDocuments } from '@/api/lightrag'
import { EraserIcon } from 'lucide-react' import { EraserIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function ClearDocumentsDialog() { export default function ClearDocumentsDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const handleClear = useCallback(async () => { const handleClear = useCallback(async () => {
try { try {
const result = await clearDocuments() const result = await clearDocuments()
if (result.status === 'success') { if (result.status === 'success') {
toast.success('Documents cleared successfully') toast.success(t('documentPanel.clearDocuments.success'))
setOpen(false) setOpen(false)
} else { } else {
toast.error(`Clear Documents Failed:\n${result.message}`) toast.error(t('documentPanel.clearDocuments.failed', { message: result.message }))
} }
} catch (err) { } catch (err) {
toast.error('Clear Documents Failed:\n' + errorMessage(err)) toast.error(t('documentPanel.clearDocuments.error', { error: errorMessage(err) }))
} }
}, [setOpen]) }, [setOpen])
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" side="bottom" tooltip='Clear documents' size="sm"> <Button variant="outline" side="bottom" tooltip={t('documentPanel.clearDocuments.tooltip')} size="sm">
<EraserIcon/> Clear <EraserIcon/> {t('documentPanel.clearDocuments.button')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}> <DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Clear documents</DialogTitle> <DialogTitle>{t('documentPanel.clearDocuments.title')}</DialogTitle>
<DialogDescription>Do you really want to clear all documents?</DialogDescription> <DialogDescription>{t('documentPanel.clearDocuments.confirm')}</DialogDescription>
</DialogHeader> </DialogHeader>
<Button variant="destructive" onClick={handleClear}> <Button variant="destructive" onClick={handleClear}>
YES {t('documentPanel.clearDocuments.confirmButton')}
</Button> </Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -14,8 +14,10 @@ import { errorMessage } from '@/lib/utils'
import { uploadDocument } from '@/api/lightrag' import { uploadDocument } from '@/api/lightrag'
import { UploadIcon } from 'lucide-react' import { UploadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function UploadDocumentsDialog() { export default function UploadDocumentsDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [progresses, setProgresses] = useState<Record<string, number>>({}) const [progresses, setProgresses] = useState<Record<string, number>>({})
@@ -29,24 +31,24 @@ export default function UploadDocumentsDialog() {
filesToUpload.map(async (file) => { filesToUpload.map(async (file) => {
try { try {
const result = await uploadDocument(file, (percentCompleted: number) => { const result = await uploadDocument(file, (percentCompleted: number) => {
console.debug(`Uploading ${file.name}: ${percentCompleted}%`) console.debug(t('documentPanel.uploadDocuments.uploading', { name: file.name, percent: percentCompleted }))
setProgresses((pre) => ({ setProgresses((pre) => ({
...pre, ...pre,
[file.name]: percentCompleted [file.name]: percentCompleted
})) }))
}) })
if (result.status === 'success') { if (result.status === 'success') {
toast.success(`Upload Success:\n${file.name} uploaded successfully`) toast.success(t('documentPanel.uploadDocuments.success', { name: file.name }))
} else { } else {
toast.error(`Upload Failed:\n${file.name}\n${result.message}`) toast.error(t('documentPanel.uploadDocuments.failed', { name: file.name, message: result.message }))
} }
} catch (err) { } catch (err) {
toast.error(`Upload Failed:\n${file.name}\n${errorMessage(err)}`) toast.error(t('documentPanel.uploadDocuments.error', { name: file.name, error: errorMessage(err) }))
} }
}) })
) )
} catch (err) { } catch (err) {
toast.error('Upload Failed\n' + errorMessage(err)) toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }))
} finally { } finally {
setIsUploading(false) setIsUploading(false)
// setOpen(false) // setOpen(false)
@@ -66,21 +68,21 @@ export default function UploadDocumentsDialog() {
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="default" side="bottom" tooltip="Upload documents" size="sm"> <Button variant="default" side="bottom" tooltip={t('documentPanel.uploadDocuments.tooltip')} size="sm">
<UploadIcon /> Upload <UploadIcon /> {t('documentPanel.uploadDocuments.button')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}> <DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Upload documents</DialogTitle> <DialogTitle>{t('documentPanel.uploadDocuments.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
Drag and drop your documents here or click to browse. {t('documentPanel.uploadDocuments.description')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<FileUploader <FileUploader
maxFileCount={Infinity} maxFileCount={Infinity}
maxSize={200 * 1024 * 1024} maxSize={200 * 1024 * 1024}
description="supported types: TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS" description={t('documentPanel.uploadDocuments.fileTypes')}
onUpload={handleDocumentsUpload} onUpload={handleDocumentsUpload}
progresses={progresses} progresses={progresses}
disabled={isUploading} disabled={isUploading}

View File

@@ -2,21 +2,23 @@ import { useFullScreen } from '@react-sigma/core'
import { MaximizeIcon, MinimizeIcon } from 'lucide-react' import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { useTranslation } from 'react-i18next'
/** /**
* Component that toggles full screen mode. * Component that toggles full screen mode.
*/ */
const FullScreenControl = () => { const FullScreenControl = () => {
const { isFullScreen, toggle } = useFullScreen() const { isFullScreen, toggle } = useFullScreen()
const { t } = useTranslation()
return ( return (
<> <>
{isFullScreen ? ( {isFullScreen ? (
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Windowed" size="icon"> <Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.windowed')} size="icon">
<MinimizeIcon /> <MinimizeIcon />
</Button> </Button>
) : ( ) : (
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Full Screen" size="icon"> <Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.fullScreen')} size="icon">
<MaximizeIcon /> <MaximizeIcon />
</Button> </Button>
)} )}

View File

@@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { labelListLimit } from '@/lib/constants' import { labelListLimit } from '@/lib/constants'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next'
const lastGraph: any = { const lastGraph: any = {
graph: null, graph: null,
@@ -13,6 +14,7 @@ const lastGraph: any = {
} }
const GraphLabels = () => { const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel() const label = useSettingsStore.use.queryLabel()
const graph = useGraphStore.use.sigmaGraph() const graph = useGraphStore.use.sigmaGraph()
@@ -69,7 +71,7 @@ const GraphLabels = () => {
return result.length <= labelListLimit return result.length <= labelListLimit
? result ? result
: [...result.slice(0, labelListLimit), `And ${result.length - labelListLimit} others`] : [...result.slice(0, labelListLimit), t('graphLabels.andOthers', { count: result.length - labelListLimit })]
}, },
[getSearchEngine] [getSearchEngine]
) )
@@ -84,14 +86,14 @@ const GraphLabels = () => {
className="ml-2" className="ml-2"
triggerClassName="max-h-8" triggerClassName="max-h-8"
searchInputClassName="max-h-8" searchInputClassName="max-h-8"
triggerTooltip="Select query label" triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
fetcher={fetchData} fetcher={fetchData}
renderOption={(item) => <div>{item}</div>} renderOption={(item) => <div>{item}</div>}
getOptionValue={(item) => item} getOptionValue={(item) => item}
getDisplayValue={(item) => <div>{item}</div>} getDisplayValue={(item) => <div>{item}</div>}
notFound={<div className="py-6 text-center text-sm">No labels found</div>} notFound={<div className="py-6 text-center text-sm">No labels found</div>}
label="Label" label={t('graphPanel.graphLabels.label')}
placeholder="Search labels..." placeholder={t('graphPanel.graphLabels.placeholder')}
value={label !== null ? label : ''} value={label !== null ? label : ''}
onChange={setQueryLabel} onChange={setQueryLabel}
/> />

View File

@@ -9,6 +9,7 @@ import { AsyncSearch } from '@/components/ui/AsyncSearch'
import { searchResultLimit } from '@/lib/constants' import { searchResultLimit } from '@/lib/constants'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next'
interface OptionItem { interface OptionItem {
id: string id: string
@@ -44,6 +45,7 @@ export const GraphSearchInput = ({
onFocus?: GraphSearchInputProps['onFocus'] onFocus?: GraphSearchInputProps['onFocus']
value?: GraphSearchInputProps['value'] value?: GraphSearchInputProps['value']
}) => { }) => {
const { t } = useTranslation()
const graph = useGraphStore.use.sigmaGraph() const graph = useGraphStore.use.sigmaGraph()
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
@@ -97,7 +99,7 @@ export const GraphSearchInput = ({
{ {
type: 'message', type: 'message',
id: messageId, id: messageId,
message: `And ${result.length - searchResultLimit} others` message: t('graphPanel.search.message', { count: result.length - searchResultLimit })
} }
] ]
}, },
@@ -118,7 +120,7 @@ export const GraphSearchInput = ({
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null) if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
}} }}
label={'item'} label={'item'}
placeholder="Search nodes..." placeholder={t('graphPanel.search.placeholder')}
/> />
) )
} }

View File

@@ -16,6 +16,7 @@ import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react' import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
type LayoutName = type LayoutName =
| 'Circular' | 'Circular'
@@ -28,6 +29,7 @@ type LayoutName =
const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => { const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
const sigma = useSigma() const sigma = useSigma()
const { stop, start, isRunning } = layout const { stop, start, isRunning } = layout
const { t } = useTranslation()
/** /**
* Init component when Sigma or component settings change. * Init component when Sigma or component settings change.
@@ -61,7 +63,7 @@ const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) =
<Button <Button
size="icon" size="icon"
onClick={() => (isRunning ? stop() : start())} onClick={() => (isRunning ? stop() : start())}
tooltip={isRunning ? 'Stop the layout animation' : 'Start the layout animation'} tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
variant={controlButtonVariant} variant={controlButtonVariant}
> >
{isRunning ? <PauseIcon /> : <PlayIcon />} {isRunning ? <PauseIcon /> : <PlayIcon />}
@@ -74,6 +76,7 @@ const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) =
*/ */
const LayoutsControl = () => { const LayoutsControl = () => {
const sigma = useSigma() const sigma = useSigma()
const { t } = useTranslation()
const [layout, setLayout] = useState<LayoutName>('Circular') const [layout, setLayout] = useState<LayoutName>('Circular')
const [opened, setOpened] = useState<boolean>(false) const [opened, setOpened] = useState<boolean>(false)
@@ -149,7 +152,7 @@ const LayoutsControl = () => {
size="icon" size="icon"
variant={controlButtonVariant} variant={controlButtonVariant}
onClick={() => setOpened((e: boolean) => !e)} onClick={() => setOpened((e: boolean) => !e)}
tooltip="Layout Graph" tooltip={t('graphPanel.sideBar.layoutsControl.layoutGraph')}
> >
<GripIcon /> <GripIcon />
</Button> </Button>
@@ -166,7 +169,7 @@ const LayoutsControl = () => {
key={name} key={name}
className="cursor-pointer text-xs" className="cursor-pointer text-xs"
> >
{name} {t(`graphPanel.sideBar.layoutsControl.layouts.${name}`)}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph' import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
import Text from '@/components/ui/Text' import Text from '@/components/ui/Text'
import useLightragGraph from '@/hooks/useLightragGraph' import useLightragGraph from '@/hooks/useLightragGraph'
import { useTranslation } from 'react-i18next'
/** /**
* Component that view properties of elements in graph. * Component that view properties of elements in graph.
@@ -147,21 +148,22 @@ const PropertyRow = ({
} }
const NodePropertiesView = ({ node }: { node: NodeType }) => { const NodePropertiesView = ({ node }: { node: NodeType }) => {
const { t } = useTranslation()
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-md pl-1 font-bold tracking-wide text-sky-300">Node</label> <label className="text-md pl-1 font-bold tracking-wide text-sky-300">{t('graphPanel.propertiesView.node.title')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={'Id'} value={node.id} /> <PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
<PropertyRow <PropertyRow
name={'Labels'} name={t('graphPanel.propertiesView.node.labels')}
value={node.labels.join(', ')} value={node.labels.join(', ')}
onClick={() => { onClick={() => {
useGraphStore.getState().setSelectedNode(node.id, true) useGraphStore.getState().setSelectedNode(node.id, true)
}} }}
/> />
<PropertyRow name={'Degree'} value={node.degree} /> <PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
</div> </div>
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label> <label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.node.properties')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(node.properties) {Object.keys(node.properties)
.sort() .sort()
@@ -172,7 +174,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
{node.relationships.length > 0 && ( {node.relationships.length > 0 && (
<> <>
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90"> <label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
Relationships {t('graphPanel.propertiesView.node.relationships')}
</label> </label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{node.relationships.map(({ type, id, label }) => { {node.relationships.map(({ type, id, label }) => {
@@ -195,28 +197,29 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
} }
const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => { const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
const { t } = useTranslation()
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-md pl-1 font-bold tracking-wide text-teal-600">Relationship</label> <label className="text-md pl-1 font-bold tracking-wide text-teal-600">{t('graphPanel.propertiesView.edge.title')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={'Id'} value={edge.id} /> <PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
{edge.type && <PropertyRow name={'Type'} value={edge.type} />} {edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
<PropertyRow <PropertyRow
name={'Source'} name={t('graphPanel.propertiesView.edge.source')}
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source} value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
onClick={() => { onClick={() => {
useGraphStore.getState().setSelectedNode(edge.source, true) useGraphStore.getState().setSelectedNode(edge.source, true)
}} }}
/> />
<PropertyRow <PropertyRow
name={'Target'} name={t('graphPanel.propertiesView.edge.target')}
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target} value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}
onClick={() => { onClick={() => {
useGraphStore.getState().setSelectedNode(edge.target, true) useGraphStore.getState().setSelectedNode(edge.target, true)
}} }}
/> />
</div> </div>
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label> <label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.edge.properties')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(edge.properties) {Object.keys(edge.properties)
.sort() .sort()

View File

@@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state' import { useBackendState } from '@/stores/state'
import { SettingsIcon } from 'lucide-react' import { SettingsIcon } from 'lucide-react'
import { useTranslation } from "react-i18next";
/** /**
* Component that displays a checkbox with a label. * Component that displays a checkbox with a label.
@@ -204,10 +205,12 @@ export default function Settings() {
[setTempApiKey] [setTempApiKey]
) )
const { t } = useTranslation();
return ( return (
<Popover open={opened} onOpenChange={setOpened}> <Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant={controlButtonVariant} tooltip="Settings" size="icon"> <Button variant={controlButtonVariant} tooltip={t("graphPanel.sideBar.settings.settings")} size="icon">
<SettingsIcon /> <SettingsIcon />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -221,7 +224,7 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={enableHealthCheck} checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck} onCheckedChange={setEnableHealthCheck}
label="Health Check" label={t("graphPanel.sideBar.settings.healthCheck")}
/> />
<Separator /> <Separator />
@@ -229,12 +232,12 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showPropertyPanel} checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel} onCheckedChange={setShowPropertyPanel}
label="Show Property Panel" label={t("graphPanel.sideBar.settings.showPropertyPanel")}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={showNodeSearchBar} checked={showNodeSearchBar}
onCheckedChange={setShowNodeSearchBar} onCheckedChange={setShowNodeSearchBar}
label="Show Search Bar" label={t("graphPanel.sideBar.settings.showSearchBar")}
/> />
<Separator /> <Separator />
@@ -242,12 +245,12 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showNodeLabel} checked={showNodeLabel}
onCheckedChange={setShowNodeLabel} onCheckedChange={setShowNodeLabel}
label="Show Node Label" label={t("graphPanel.sideBar.settings.showNodeLabel")}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableNodeDrag} checked={enableNodeDrag}
onCheckedChange={setEnableNodeDrag} onCheckedChange={setEnableNodeDrag}
label="Node Draggable" label={t("graphPanel.sideBar.settings.nodeDraggable")}
/> />
<Separator /> <Separator />
@@ -255,51 +258,50 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showEdgeLabel} checked={showEdgeLabel}
onCheckedChange={setShowEdgeLabel} onCheckedChange={setShowEdgeLabel}
label="Show Edge Label" label={t("graphPanel.sideBar.settings.showEdgeLabel")}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableHideUnselectedEdges} checked={enableHideUnselectedEdges}
onCheckedChange={setEnableHideUnselectedEdges} onCheckedChange={setEnableHideUnselectedEdges}
label="Hide Unselected Edges" label={t("graphPanel.sideBar.settings.hideUnselectedEdges")}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableEdgeEvents} checked={enableEdgeEvents}
onCheckedChange={setEnableEdgeEvents} onCheckedChange={setEnableEdgeEvents}
label="Edge Events" label={t("graphPanel.sideBar.settings.edgeEvents")}
/> />
<Separator /> <Separator />
<LabeledNumberInput <LabeledNumberInput
label="Max Query Depth" label={t("graphPanel.sideBar.settings.maxQueryDepth")}
min={1} min={1}
value={graphQueryMaxDepth} value={graphQueryMaxDepth}
onEditFinished={setGraphQueryMaxDepth} onEditFinished={setGraphQueryMaxDepth}
/> />
<LabeledNumberInput <LabeledNumberInput
label="Minimum Degree" label={t("graphPanel.sideBar.settings.minDegree")}
min={0} min={0}
value={graphMinDegree} value={graphMinDegree}
onEditFinished={setGraphMinDegree} onEditFinished={setGraphMinDegree}
/> />
<LabeledNumberInput <LabeledNumberInput
label="Max Layout Iterations" label={t("graphPanel.sideBar.settings.maxLayoutIterations")}
min={1} min={1}
max={20} max={20}
value={graphLayoutMaxIterations} value={graphLayoutMaxIterations}
onEditFinished={setGraphLayoutMaxIterations} onEditFinished={setGraphLayoutMaxIterations}
/> />
<Separator /> <Separator />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium">API Key</label> <label className="text-sm font-medium">{t("graphPanel.sideBar.settings.apiKey")}</label>
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}> <form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
<div className="w-0 flex-1"> <div className="w-0 flex-1">
<Input <Input
type="password" type="password"
value={tempApiKey} value={tempApiKey}
onChange={handleTempApiKeyChange} onChange={handleTempApiKeyChange}
placeholder="Enter your API key" placeholder={t("graphPanel.sideBar.settings.enterYourAPIkey")}
className="max-h-full w-full min-w-0" className="max-h-full w-full min-w-0"
autoComplete="off" autoComplete="off"
/> />
@@ -310,7 +312,7 @@ export default function Settings() {
size="sm" size="sm"
className="max-h-full shrink-0" className="max-h-full shrink-0"
> >
Save {t("graphPanel.sideBar.settings.save")}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -1,58 +1,60 @@
import { LightragStatus } from '@/api/lightrag' import { LightragStatus } from '@/api/lightrag'
import { useTranslation } from 'react-i18next'
const StatusCard = ({ status }: { status: LightragStatus | null }) => { const StatusCard = ({ status }: { status: LightragStatus | null }) => {
const { t } = useTranslation()
if (!status) { if (!status) {
return <div className="text-muted-foreground text-sm">Status information unavailable</div> return <div className="text-muted-foreground text-sm">{t('graphPanel.statusCard.unavailable')}</div>
} }
return ( return (
<div className="min-w-[300px] space-y-3 text-sm"> <div className="min-w-[300px] space-y-3 text-sm">
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">Storage Info</h4> <h4 className="font-medium">{t('graphPanel.statusCard.storageInfo')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>Working Directory:</span> <span>{t('graphPanel.statusCard.workingDirectory')}:</span>
<span className="truncate">{status.working_directory}</span> <span className="truncate">{status.working_directory}</span>
<span>Input Directory:</span> <span>{t('graphPanel.statusCard.inputDirectory')}:</span>
<span className="truncate">{status.input_directory}</span> <span className="truncate">{status.input_directory}</span>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">LLM Configuration</h4> <h4 className="font-medium">{t('graphPanel.statusCard.llmConfig')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>LLM Binding:</span> <span>{t('graphPanel.statusCard.llmBinding')}:</span>
<span>{status.configuration.llm_binding}</span> <span>{status.configuration.llm_binding}</span>
<span>LLM Binding Host:</span> <span>{t('graphPanel.statusCard.llmBindingHost')}:</span>
<span>{status.configuration.llm_binding_host}</span> <span>{status.configuration.llm_binding_host}</span>
<span>LLM Model:</span> <span>{t('graphPanel.statusCard.llmModel')}:</span>
<span>{status.configuration.llm_model}</span> <span>{status.configuration.llm_model}</span>
<span>Max Tokens:</span> <span>{t('graphPanel.statusCard.maxTokens')}:</span>
<span>{status.configuration.max_tokens}</span> <span>{status.configuration.max_tokens}</span>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">Embedding Configuration</h4> <h4 className="font-medium">{t('graphPanel.statusCard.embeddingConfig')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>Embedding Binding:</span> <span>{t('graphPanel.statusCard.embeddingBinding')}:</span>
<span>{status.configuration.embedding_binding}</span> <span>{status.configuration.embedding_binding}</span>
<span>Embedding Binding Host:</span> <span>{t('graphPanel.statusCard.embeddingBindingHost')}:</span>
<span>{status.configuration.embedding_binding_host}</span> <span>{status.configuration.embedding_binding_host}</span>
<span>Embedding Model:</span> <span>{t('graphPanel.statusCard.embeddingModel')}:</span>
<span>{status.configuration.embedding_model}</span> <span>{status.configuration.embedding_model}</span>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">Storage Configuration</h4> <h4 className="font-medium">{t('graphPanel.statusCard.storageConfig')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>KV Storage:</span> <span>{t('graphPanel.statusCard.kvStorage')}:</span>
<span>{status.configuration.kv_storage}</span> <span>{status.configuration.kv_storage}</span>
<span>Doc Status Storage:</span> <span>{t('graphPanel.statusCard.docStatusStorage')}:</span>
<span>{status.configuration.doc_status_storage}</span> <span>{status.configuration.doc_status_storage}</span>
<span>Graph Storage:</span> <span>{t('graphPanel.statusCard.graphStorage')}:</span>
<span>{status.configuration.graph_storage}</span> <span>{status.configuration.graph_storage}</span>
<span>Vector Storage:</span> <span>{t('graphPanel.statusCard.vectorStorage')}:</span>
<span>{status.configuration.vector_storage}</span> <span>{status.configuration.vector_storage}</span>
</div> </div>
</div> </div>

View File

@@ -3,8 +3,10 @@ import { useBackendState } from '@/stores/state'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import StatusCard from '@/components/graph/StatusCard' import StatusCard from '@/components/graph/StatusCard'
import { useTranslation } from 'react-i18next'
const StatusIndicator = () => { const StatusIndicator = () => {
const { t } = useTranslation()
const health = useBackendState.use.health() const health = useBackendState.use.health()
const lastCheckTime = useBackendState.use.lastCheckTime() const lastCheckTime = useBackendState.use.lastCheckTime()
const status = useBackendState.use.status() const status = useBackendState.use.status()
@@ -33,7 +35,7 @@ const StatusIndicator = () => {
)} )}
/> />
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{health ? 'Connected' : 'Disconnected'} {health ? t('graphPanel.statusIndicator.connected') : t('graphPanel.statusIndicator.disconnected')}
</span> </span>
</div> </div>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -3,12 +3,14 @@ import { useCallback } from 'react'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react' import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from "react-i18next";
/** /**
* Component that provides zoom controls for the graph viewer. * Component that provides zoom controls for the graph viewer.
*/ */
const ZoomControl = () => { const ZoomControl = () => {
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 }) const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
const { t } = useTranslation();
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn]) const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut]) const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
@@ -16,16 +18,16 @@ const ZoomControl = () => {
return ( return (
<> <>
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip="Zoom In" size="icon"> <Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t("graphPanel.sideBar.zoomControl.zoomIn")} size="icon">
<ZoomInIcon /> <ZoomInIcon />
</Button> </Button>
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip="Zoom Out" size="icon"> <Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t("graphPanel.sideBar.zoomControl.zoomOut")} size="icon">
<ZoomOutIcon /> <ZoomOutIcon />
</Button> </Button>
<Button <Button
variant={controlButtonVariant} variant={controlButtonVariant}
onClick={handleResetZoom} onClick={handleResetZoom}
tooltip="Reset Zoom" tooltip={t("graphPanel.sideBar.zoomControl.resetZoom")}
size="icon" size="icon"
> >
<FullscreenIcon /> <FullscreenIcon />

View File

@@ -15,18 +15,21 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { LoaderIcon, CopyIcon } from 'lucide-react' import { LoaderIcon, CopyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export type MessageWithError = Message & { export type MessageWithError = Message & {
isError?: boolean isError?: boolean
} }
export const ChatMessage = ({ message }: { message: MessageWithError }) => { export const ChatMessage = ({ message }: { message: MessageWithError }) => {
const { t } = useTranslation()
const handleCopyMarkdown = useCallback(async () => { const handleCopyMarkdown = useCallback(async () => {
if (message.content) { if (message.content) {
try { try {
await navigator.clipboard.writeText(message.content) await navigator.clipboard.writeText(message.content)
} catch (err) { } catch (err) {
console.error('Failed to copy:', err) console.error(t('chat.copyError'), err)
} }
} }
}, [message]) }, [message])
@@ -57,7 +60,7 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => {
<Button <Button
onClick={handleCopyMarkdown} onClick={handleCopyMarkdown}
className="absolute right-0 bottom-0 size-6 rounded-md opacity-20 transition-opacity hover:opacity-100" className="absolute right-0 bottom-0 size-6 rounded-md opacity-20 transition-opacity hover:opacity-100"
tooltip="Copy to clipboard" tooltip={t('retrievePanel.chatMessage.copyTooltip')}
variant="default" variant="default"
size="icon" size="icon"
> >

View File

@@ -14,8 +14,10 @@ import {
SelectValue SelectValue
} from '@/components/ui/Select' } from '@/components/ui/Select'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
export default function QuerySettings() { export default function QuerySettings() {
const { t } = useTranslation()
const querySettings = useSettingsStore((state) => state.querySettings) const querySettings = useSettingsStore((state) => state.querySettings)
const handleChange = useCallback((key: keyof QueryRequest, value: any) => { const handleChange = useCallback((key: keyof QueryRequest, value: any) => {
@@ -25,8 +27,8 @@ export default function QuerySettings() {
return ( return (
<Card className="flex shrink-0 flex-col"> <Card className="flex shrink-0 flex-col">
<CardHeader className="px-4 pt-4 pb-2"> <CardHeader className="px-4 pt-4 pb-2">
<CardTitle>Parameters</CardTitle> <CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
<CardDescription>Configure your query parameters</CardDescription> <CardDescription>{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="m-0 flex grow flex-col p-0 text-xs"> <CardContent className="m-0 flex grow flex-col p-0 text-xs">
<div className="relative size-full"> <div className="relative size-full">
@@ -35,8 +37,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Query Mode" text={t('retrievePanel.querySettings.queryMode')}
tooltip="Select the retrieval strategy:\n• Naive: Basic search without advanced techniques\n• Local: Context-dependent information retrieval\n• Global: Utilizes global knowledge base\n• Hybrid: Combines local and global retrieval\n• Mix: Integrates knowledge graph with vector retrieval" tooltip={t('retrievePanel.querySettings.queryModeTooltip')}
side="left" side="left"
/> />
<Select <Select
@@ -48,11 +50,11 @@ export default function QuerySettings() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="naive">Naive</SelectItem> <SelectItem value="naive">{t('retrievePanel.querySettings.queryModeOptions.naive')}</SelectItem>
<SelectItem value="local">Local</SelectItem> <SelectItem value="local">{t('retrievePanel.querySettings.queryModeOptions.local')}</SelectItem>
<SelectItem value="global">Global</SelectItem> <SelectItem value="global">{t('retrievePanel.querySettings.queryModeOptions.global')}</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem> <SelectItem value="hybrid">{t('retrievePanel.querySettings.queryModeOptions.hybrid')}</SelectItem>
<SelectItem value="mix">Mix</SelectItem> <SelectItem value="mix">{t('retrievePanel.querySettings.queryModeOptions.mix')}</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -62,8 +64,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Response Format" text={t('retrievePanel.querySettings.responseFormat')}
tooltip="Defines the response format. Examples:\n• Multiple Paragraphs\n• Single Paragraph\n• Bullet Points" tooltip={t('retrievePanel.querySettings.responseFormatTooltip')}
side="left" side="left"
/> />
<Select <Select
@@ -75,9 +77,9 @@ export default function QuerySettings() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="Multiple Paragraphs">Multiple Paragraphs</SelectItem> <SelectItem value="Multiple Paragraphs">{t('retrievePanel.querySettings.responseFormatOptions.multipleParagraphs')}</SelectItem>
<SelectItem value="Single Paragraph">Single Paragraph</SelectItem> <SelectItem value="Single Paragraph">{t('retrievePanel.querySettings.responseFormatOptions.singleParagraph')}</SelectItem>
<SelectItem value="Bullet Points">Bullet Points</SelectItem> <SelectItem value="Bullet Points">{t('retrievePanel.querySettings.responseFormatOptions.bulletPoints')}</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -87,8 +89,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Top K Results" text={t('retrievePanel.querySettings.topK')}
tooltip="Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode" tooltip={t('retrievePanel.querySettings.topKTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -97,7 +99,7 @@ export default function QuerySettings() {
value={querySettings.top_k} value={querySettings.top_k}
onValueChange={(v) => handleChange('top_k', v)} onValueChange={(v) => handleChange('top_k', v)}
min={1} min={1}
placeholder="Number of results" placeholder={t('retrievePanel.querySettings.topKPlaceholder')}
/> />
</> </>
@@ -106,8 +108,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Max Tokens for Text Unit" text={t('retrievePanel.querySettings.maxTokensTextUnit')}
tooltip="Maximum number of tokens allowed for each retrieved text chunk" tooltip={t('retrievePanel.querySettings.maxTokensTextUnitTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -116,14 +118,14 @@ export default function QuerySettings() {
value={querySettings.max_token_for_text_unit} value={querySettings.max_token_for_text_unit}
onValueChange={(v) => handleChange('max_token_for_text_unit', v)} onValueChange={(v) => handleChange('max_token_for_text_unit', v)}
min={1} min={1}
placeholder="Max tokens for text unit" placeholder={t('retrievePanel.querySettings.maxTokensTextUnit')}
/> />
</> </>
<> <>
<Text <Text
text="Max Tokens for Global Context" text={t('retrievePanel.querySettings.maxTokensGlobalContext')}
tooltip="Maximum number of tokens allocated for relationship descriptions in global retrieval" tooltip={t('retrievePanel.querySettings.maxTokensGlobalContextTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -132,15 +134,15 @@ export default function QuerySettings() {
value={querySettings.max_token_for_global_context} value={querySettings.max_token_for_global_context}
onValueChange={(v) => handleChange('max_token_for_global_context', v)} onValueChange={(v) => handleChange('max_token_for_global_context', v)}
min={1} min={1}
placeholder="Max tokens for global context" placeholder={t('retrievePanel.querySettings.maxTokensGlobalContext')}
/> />
</> </>
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Max Tokens for Local Context" text={t('retrievePanel.querySettings.maxTokensLocalContext')}
tooltip="Maximum number of tokens allocated for entity descriptions in local retrieval" tooltip={t('retrievePanel.querySettings.maxTokensLocalContextTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -149,7 +151,7 @@ export default function QuerySettings() {
value={querySettings.max_token_for_local_context} value={querySettings.max_token_for_local_context}
onValueChange={(v) => handleChange('max_token_for_local_context', v)} onValueChange={(v) => handleChange('max_token_for_local_context', v)}
min={1} min={1}
placeholder="Max tokens for local context" placeholder={t('retrievePanel.querySettings.maxTokensLocalContext')}
/> />
</> </>
</> </>
@@ -158,8 +160,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="History Turns" text={t('retrievePanel.querySettings.historyTurns')}
tooltip="Number of complete conversation turns (user-assistant pairs) to consider in the response context" tooltip={t('retrievePanel.querySettings.historyTurnsTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -170,7 +172,7 @@ export default function QuerySettings() {
value={querySettings.history_turns} value={querySettings.history_turns}
onValueChange={(v) => handleChange('history_turns', v)} onValueChange={(v) => handleChange('history_turns', v)}
min={0} min={0}
placeholder="Number of history turns" placeholder={t('retrievePanel.querySettings.historyTurnsPlaceholder')}
/> />
</> </>
@@ -179,8 +181,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="High-Level Keywords" text={t('retrievePanel.querySettings.hlKeywords')}
tooltip="List of high-level keywords to prioritize in retrieval. Separate with commas" tooltip={t('retrievePanel.querySettings.hlKeywordsTooltip')}
side="left" side="left"
/> />
<Input <Input
@@ -194,15 +196,15 @@ export default function QuerySettings() {
.filter((k) => k !== '') .filter((k) => k !== '')
handleChange('hl_keywords', keywords) handleChange('hl_keywords', keywords)
}} }}
placeholder="Enter keywords" placeholder={t('retrievePanel.querySettings.hlkeywordsPlaceHolder')}
/> />
</> </>
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Low-Level Keywords" text={t('retrievePanel.querySettings.llKeywords')}
tooltip="List of low-level keywords to refine retrieval focus. Separate with commas" tooltip={t('retrievePanel.querySettings.llKeywordsTooltip')}
side="left" side="left"
/> />
<Input <Input
@@ -216,7 +218,7 @@ export default function QuerySettings() {
.filter((k) => k !== '') .filter((k) => k !== '')
handleChange('ll_keywords', keywords) handleChange('ll_keywords', keywords)
}} }}
placeholder="Enter keywords" placeholder={t('retrievePanel.querySettings.hlkeywordsPlaceHolder')}
/> />
</> </>
</> </>
@@ -226,8 +228,8 @@ export default function QuerySettings() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text <Text
className="ml-1" className="ml-1"
text="Only Need Context" text={t('retrievePanel.querySettings.onlyNeedContext')}
tooltip="If True, only returns the retrieved context without generating a response" tooltip={t('retrievePanel.querySettings.onlyNeedContextTooltip')}
side="left" side="left"
/> />
<div className="grow" /> <div className="grow" />
@@ -242,8 +244,8 @@ export default function QuerySettings() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text <Text
className="ml-1" className="ml-1"
text="Only Need Prompt" text={t('retrievePanel.querySettings.onlyNeedPrompt')}
tooltip="If True, only returns the generated prompt without producing a response" tooltip={t('retrievePanel.querySettings.onlyNeedPromptTooltip')}
side="left" side="left"
/> />
<div className="grow" /> <div className="grow" />
@@ -258,8 +260,8 @@ export default function QuerySettings() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text <Text
className="ml-1" className="ml-1"
text="Stream Response" text={t('retrievePanel.querySettings.streamResponse')}
tooltip="If True, enables streaming output for real-time responses" tooltip={t('retrievePanel.querySettings.streamResponseTooltip')}
side="left" side="left"
/> />
<div className="grow" /> <div className="grow" />

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { import {
Table, Table,
@@ -22,6 +23,7 @@ import { useBackendState } from '@/stores/state'
import { RefreshCwIcon } from 'lucide-react' import { RefreshCwIcon } from 'lucide-react'
export default function DocumentManager() { export default function DocumentManager() {
const { t } = useTranslation()
const health = useBackendState.use.health() const health = useBackendState.use.health()
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null) const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
@@ -44,7 +46,7 @@ export default function DocumentManager() {
setDocs(null) setDocs(null)
} }
} catch (err) { } catch (err) {
toast.error('Failed to load documents\n' + errorMessage(err)) toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }))
} }
}, [setDocs]) }, [setDocs])
@@ -57,7 +59,7 @@ export default function DocumentManager() {
const { status } = await scanNewDocuments() const { status } = await scanNewDocuments()
toast.message(status) toast.message(status)
} catch (err) { } catch (err) {
toast.error('Failed to load documents\n' + errorMessage(err)) toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) }))
} }
}, []) }, [])
@@ -69,7 +71,7 @@ export default function DocumentManager() {
try { try {
await fetchDocuments() await fetchDocuments()
} catch (err) { } catch (err) {
toast.error('Failed to get scan progress\n' + errorMessage(err)) toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) }))
} }
}, 5000) }, 5000)
return () => clearInterval(interval) return () => clearInterval(interval)
@@ -78,7 +80,7 @@ export default function DocumentManager() {
return ( return (
<Card className="!size-full !rounded-none !border-none"> <Card className="!size-full !rounded-none !border-none">
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Document Management</CardTitle> <CardTitle className="text-lg">{t('documentPanel.documentManager.title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -86,10 +88,10 @@ export default function DocumentManager() {
variant="outline" variant="outline"
onClick={scanDocuments} onClick={scanDocuments}
side="bottom" side="bottom"
tooltip="Scan documents" tooltip={t('documentPanel.documentManager.scanTooltip')}
size="sm" size="sm"
> >
<RefreshCwIcon /> Scan <RefreshCwIcon /> {t('documentPanel.documentManager.scanButton')}
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<ClearDocumentsDialog /> <ClearDocumentsDialog />
@@ -98,29 +100,29 @@ export default function DocumentManager() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Uploaded documents</CardTitle> <CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle>
<CardDescription>view the uploaded documents here</CardDescription> <CardDescription>{t('documentPanel.documentManager.uploadedDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!docs && ( {!docs && (
<EmptyCard <EmptyCard
title="No documents uploaded" title={t('documentPanel.documentManager.emptyTitle')}
description="upload documents to see them here" description={t('documentPanel.documentManager.emptyDescription')}
/> />
)} )}
{docs && ( {docs && (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead> <TableHead>{t('documentPanel.documentManager.columns.id')}</TableHead>
<TableHead>Summary</TableHead> <TableHead>{t('documentPanel.documentManager.columns.summary')}</TableHead>
<TableHead>Status</TableHead> <TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead>
<TableHead>Length</TableHead> <TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead>
<TableHead>Chunks</TableHead> <TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead>
<TableHead>Created</TableHead> <TableHead>{t('documentPanel.documentManager.columns.created')}</TableHead>
<TableHead>Updated</TableHead> <TableHead>{t('documentPanel.documentManager.columns.updated')}</TableHead>
<TableHead>Metadata</TableHead> <TableHead>{t('documentPanel.documentManager.columns.metadata')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="text-sm"> <TableBody className="text-sm">
@@ -137,13 +139,13 @@ export default function DocumentManager() {
</TableCell> </TableCell>
<TableCell> <TableCell>
{status === 'processed' && ( {status === 'processed' && (
<span className="text-green-600">Completed</span> <span className="text-green-600">{t('documentPanel.documentManager.status.completed')}</span>
)} )}
{status === 'processing' && ( {status === 'processing' && (
<span className="text-blue-600">Processing</span> <span className="text-blue-600">{t('documentPanel.documentManager.status.processing')}</span>
)} )}
{status === 'pending' && <span className="text-yellow-600">Pending</span>} {status === 'pending' && <span className="text-yellow-600">{t('documentPanel.documentManager.status.pending')}</span>}
{status === 'failed' && <span className="text-red-600">Failed</span>} {status === 'failed' && <span className="text-red-600">{t('documentPanel.documentManager.status.failed')}</span>}
{doc.error && ( {doc.error && (
<span className="ml-2 text-red-500" title={doc.error}> <span className="ml-2 text-red-500" title={doc.error}>

View File

@@ -8,8 +8,10 @@ import { useDebounce } from '@/hooks/useDebounce'
import QuerySettings from '@/components/retrieval/QuerySettings' import QuerySettings from '@/components/retrieval/QuerySettings'
import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage' import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage'
import { EraserIcon, SendIcon } from 'lucide-react' import { EraserIcon, SendIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function RetrievalTesting() { export default function RetrievalTesting() {
const { t } = useTranslation()
const [messages, setMessages] = useState<MessageWithError[]>( const [messages, setMessages] = useState<MessageWithError[]>(
() => useSettingsStore.getState().retrievalHistory || [] () => useSettingsStore.getState().retrievalHistory || []
) )
@@ -89,7 +91,7 @@ export default function RetrievalTesting() {
} }
} catch (err) { } catch (err) {
// Handle error // Handle error
updateAssistantMessage(`Error: Failed to get response\n${errorMessage(err)}`, true) updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\n${errorMessage(err)}`, true)
} finally { } finally {
// Clear loading and add messages to state // Clear loading and add messages to state
setIsLoading(false) setIsLoading(false)
@@ -98,7 +100,7 @@ export default function RetrievalTesting() {
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage]) .setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
} }
}, },
[inputValue, isLoading, messages, setMessages] [inputValue, isLoading, messages, setMessages, t]
) )
const debouncedMessages = useDebounce(messages, 100) const debouncedMessages = useDebounce(messages, 100)
@@ -117,7 +119,7 @@ export default function RetrievalTesting() {
<div className="flex min-h-0 flex-1 flex-col gap-2"> <div className="flex min-h-0 flex-1 flex-col gap-2">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-lg"> <div className="text-muted-foreground flex h-full items-center justify-center text-lg">
Start a retrieval by typing your query below {t('retrievePanel.retrieval.startPrompt')}
</div> </div>
) : ( ) : (
messages.map((message, idx) => ( messages.map((message, idx) => (
@@ -143,18 +145,18 @@ export default function RetrievalTesting() {
size="sm" size="sm"
> >
<EraserIcon /> <EraserIcon />
Clear {t('retrievePanel.retrieval.clear')}
</Button> </Button>
<Input <Input
className="flex-1" className="flex-1"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
placeholder="Type your query..." placeholder={t('retrievePanel.retrieval.placeholder')}
disabled={isLoading} disabled={isLoading}
/> />
<Button type="submit" variant="default" disabled={isLoading} size="sm"> <Button type="submit" variant="default" disabled={isLoading} size="sm">
<SendIcon /> <SendIcon />
Send {t('retrievePanel.retrieval.send')}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -4,6 +4,7 @@ import ThemeToggle from '@/components/ThemeToggle'
import { TabsList, TabsTrigger } from '@/components/ui/Tabs' import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { ZapIcon, GithubIcon } from 'lucide-react' import { ZapIcon, GithubIcon } from 'lucide-react'
@@ -29,21 +30,22 @@ function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
function TabsNavigation() { function TabsNavigation() {
const currentTab = useSettingsStore.use.currentTab() const currentTab = useSettingsStore.use.currentTab()
const { t } = useTranslation()
return ( return (
<div className="flex h-8 self-center"> <div className="flex h-8 self-center">
<TabsList className="h-full gap-2"> <TabsList className="h-full gap-2">
<NavigationTab value="documents" currentTab={currentTab}> <NavigationTab value="documents" currentTab={currentTab}>
Documents {t('header.documents')}
</NavigationTab> </NavigationTab>
<NavigationTab value="knowledge-graph" currentTab={currentTab}> <NavigationTab value="knowledge-graph" currentTab={currentTab}>
Knowledge Graph {t('header.knowledgeGraph')}
</NavigationTab> </NavigationTab>
<NavigationTab value="retrieval" currentTab={currentTab}> <NavigationTab value="retrieval" currentTab={currentTab}>
Retrieval {t('header.retrieval')}
</NavigationTab> </NavigationTab>
<NavigationTab value="api" currentTab={currentTab}> <NavigationTab value="api" currentTab={currentTab}>
API {t('header.api')}
</NavigationTab> </NavigationTab>
</TabsList> </TabsList>
</div> </div>
@@ -51,6 +53,7 @@ function TabsNavigation() {
} }
export default function SiteHeader() { export default function SiteHeader() {
const { t } = useTranslation()
return ( return (
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur"> <header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
<a href="/" className="mr-6 flex items-center gap-2"> <a href="/" className="mr-6 flex items-center gap-2">
@@ -64,7 +67,7 @@ export default function SiteHeader() {
</div> </div>
<nav className="flex items-center"> <nav className="flex items-center">
<Button variant="ghost" size="icon" side="bottom" tooltip="Project Repository"> <Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer"> <a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
<GithubIcon className="size-4" aria-hidden="true" /> <GithubIcon className="size-4" aria-hidden="true" />
</a> </a>

View File

@@ -0,0 +1,21 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
lng: "en", // default
fallbackLng: "en",
interpolation: {
escapeValue: false
}
});
export default i18n;

View File

@@ -0,0 +1,234 @@
{
"header": {
"documents": "Documents",
"knowledgeGraph": "Knowledge Graph",
"retrieval": "Retrieval",
"api": "API",
"projectRepository": "Project Repository",
"themeToggle": {
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
}
},
"documentPanel": {
"clearDocuments": {
"button": "Clear",
"tooltip": "Clear documents",
"title": "Clear Documents",
"confirm": "Do you really want to clear all documents?",
"confirmButton": "YES",
"success": "Documents cleared successfully",
"failed": "Clear Documents Failed:\n{{message}}",
"error": "Clear Documents Failed:\n{{error}}"
},
"uploadDocuments": {
"button": "Upload",
"tooltip": "Upload documents",
"title": "Upload Documents",
"description": "Drag and drop your documents here or click to browse.",
"uploading": "Uploading {{name}}: {{percent}}%",
"success": "Upload Success:\n{{name}} uploaded successfully",
"failed": "Upload Failed:\n{{name}}\n{{message}}",
"error": "Upload Failed:\n{{name}}\n{{error}}",
"generalError": "Upload Failed\n{{error}}",
"fileTypes": "Supported types: TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS"
},
"documentManager": {
"title": "Document Management",
"scanButton": "Scan",
"scanTooltip": "Scan documents",
"uploadedTitle": "Uploaded Documents",
"uploadedDescription": "List of uploaded documents and their statuses.",
"emptyTitle": "No Documents",
"emptyDescription": "There are no uploaded documents yet.",
"columns": {
"id": "ID",
"summary": "Summary",
"status": "Status",
"length": "Length",
"chunks": "Chunks",
"created": "Created",
"updated": "Updated",
"metadata": "Metadata"
},
"status": {
"completed": "Completed",
"processing": "Processing",
"pending": "Pending",
"failed": "Failed"
},
"errors": {
"loadFailed": "Failed to load documents\n{{error}}",
"scanFailed": "Failed to scan documents\n{{error}}",
"scanProgressFailed": "Failed to get scan progress\n{{error}}"
}
}
},
"graphPanel": {
"sideBar": {
"settings": {
"settings": "Settings",
"healthCheck": "Health Check",
"showPropertyPanel": "Show Property Panel",
"showSearchBar": "Show Search Bar",
"showNodeLabel": "Show Node Label",
"nodeDraggable": "Node Draggable",
"showEdgeLabel": "Show Edge Label",
"hideUnselectedEdges": "Hide Unselected Edges",
"edgeEvents": "Edge Events",
"maxQueryDepth": "Max Query Depth",
"minDegree": "Minimum Degree",
"maxLayoutIterations": "Max Layout Iterations",
"apiKey": "API Key",
"enterYourAPIkey": "Enter your API key",
"save": "Save"
},
"zoomControl": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetZoom": "Reset Zoom"
},
"layoutsControl": {
"startAnimation": "Start the layout animation",
"stopAnimation": "Stop the layout animation",
"layoutGraph": "Layout Graph",
"layouts": {
"Circular": "Circular",
"Circlepack": "Circlepack",
"Random": "Random",
"Noverlaps": "Noverlaps",
"Force Directed": "Force Directed",
"Force Atlas": "Force Atlas"
}
},
"fullScreenControl": {
"fullScreen": "Full Screen",
"windowed": "Windowed"
}
},
"statusIndicator": {
"connected": "Connected",
"disconnected": "Disconnected"
},
"statusCard": {
"unavailable": "Status information unavailable",
"storageInfo": "Storage Info",
"workingDirectory": "Working Directory",
"inputDirectory": "Input Directory",
"llmConfig": "LLM Configuration",
"llmBinding": "LLM Binding",
"llmBindingHost": "LLM Binding Host",
"llmModel": "LLM Model",
"maxTokens": "Max Tokens",
"embeddingConfig": "Embedding Configuration",
"embeddingBinding": "Embedding Binding",
"embeddingBindingHost": "Embedding Binding Host",
"embeddingModel": "Embedding Model",
"storageConfig": "Storage Configuration",
"kvStorage": "KV Storage",
"docStatusStorage": "Doc Status Storage",
"graphStorage": "Graph Storage",
"vectorStorage": "Vector Storage"
},
"propertiesView": {
"node": {
"title": "Node",
"id": "ID",
"labels": "Labels",
"degree": "Degree",
"properties": "Properties",
"relationships": "Relationships"
},
"edge": {
"title": "Relationship",
"id": "ID",
"type": "Type",
"source": "Source",
"target": "Target",
"properties": "Properties"
}
},
"search": {
"placeholder": "Search nodes...",
"message": "And {count} others"
},
"graphLabels": {
"selectTooltip": "Select query label",
"noLabels": "No labels found",
"label": "Label",
"placeholder": "Search labels...",
"andOthers": "And {count} others"
}
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "Copy to clipboard",
"copyError": "Failed to copy text to clipboard"
},
"retrieval": {
"startPrompt": "Start a retrieval by typing your query below",
"clear": "Clear",
"send": "Send",
"placeholder": "Type your query...",
"error": "Error: Failed to get response"
},
"querySettings": {
"parametersTitle": "Parameters",
"parametersDescription": "Configure your query parameters",
"queryMode": "Query Mode",
"queryModeTooltip": "Select the retrieval strategy:\n• Naive: Basic search without advanced techniques\n• Local: Context-dependent information retrieval\n• Global: Utilizes global knowledge base\n• Hybrid: Combines local and global retrieval\n• Mix: Integrates knowledge graph with vector retrieval",
"queryModeOptions": {
"naive": "Naive",
"local": "Local",
"global": "Global",
"hybrid": "Hybrid",
"mix": "Mix"
},
"responseFormat": "Response Format",
"responseFormatTooltip": "Defines the response format. Examples:\n• Multiple Paragraphs\n• Single Paragraph\n• Bullet Points",
"responseFormatOptions": {
"multipleParagraphs": "Multiple Paragraphs",
"singleParagraph": "Single Paragraph",
"bulletPoints": "Bullet Points"
},
"topK": "Top K Results",
"topKTooltip": "Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode",
"topKPlaceholder": "Number of results",
"maxTokensTextUnit": "Max Tokens for Text Unit",
"maxTokensTextUnitTooltip": "Maximum number of tokens allowed for each retrieved text chunk",
"maxTokensGlobalContext": "Max Tokens for Global Context",
"maxTokensGlobalContextTooltip": "Maximum number of tokens allocated for relationship descriptions in global retrieval",
"maxTokensLocalContext": "Max Tokens for Local Context",
"maxTokensLocalContextTooltip": "Maximum number of tokens allocated for entity descriptions in local retrieval",
"historyTurns": "History Turns",
"historyTurnsTooltip": "Number of complete conversation turns (user-assistant pairs) to consider in the response context",
"historyTurnsPlaceholder": "Number of history turns",
"hlKeywords": "High-Level Keywords",
"hlKeywordsTooltip": "List of high-level keywords to prioritize in retrieval. Separate with commas",
"hlkeywordsPlaceHolder": "Enter keywords",
"llKeywords": "Low-Level Keywords",
"llKeywordsTooltip": "List of low-level keywords to refine retrieval focus. Separate with commas",
"onlyNeedContext": "Only Need Context",
"onlyNeedContextTooltip": "If True, only returns the retrieved context without generating a response",
"onlyNeedPrompt": "Only Need Prompt",
"onlyNeedPromptTooltip": "If True, only returns the generated prompt without producing a response",
"streamResponse": "Stream Response",
"streamResponseTooltip": "If True, enables streaming output for real-time responses"
}
}
}

View File

@@ -0,0 +1,235 @@
{
"header": {
"documents": "文档",
"knowledgeGraph": "知识图谱",
"retrieval": "检索",
"api": "API",
"projectRepository": "项目仓库",
"themeToggle": {
"switchToLight": "切换到亮色主题",
"switchToDark": "切换到暗色主题"
}
},
"documentPanel": {
"clearDocuments": {
"button": "清除",
"tooltip": "清除文档",
"title": "清除文档",
"confirm": "您确定要清除所有文档吗?",
"confirmButton": "确定",
"success": "文档已成功清除",
"failed": "清除文档失败:\n{{message}}",
"error": "清除文档失败:\n{{error}}"
},
"uploadDocuments": {
"button": "上传",
"tooltip": "上传文档",
"title": "上传文档",
"description": "拖放文档到此处或点击浏览。",
"uploading": "正在上传 {{name}}: {{percent}}%",
"success": "上传成功:\n{{name}} 上传成功",
"failed": "上传失败:\n{{name}}\n{{message}}",
"error": "上传失败:\n{{name}}\n{{error}}",
"generalError": "上传失败\n{{error}}",
"fileTypes": "支持的文件类型: TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS"
},
"documentManager": {
"title": "文档管理",
"scanButton": "扫描",
"scanTooltip": "扫描文档",
"uploadedTitle": "已上传文档",
"uploadedDescription": "已上传文档及其状态列表。",
"emptyTitle": "暂无文档",
"emptyDescription": "尚未上传任何文档。",
"columns": {
"id": "ID",
"summary": "摘要",
"status": "状态",
"length": "长度",
"chunks": "分块",
"created": "创建时间",
"updated": "更新时间",
"metadata": "元数据"
},
"status": {
"completed": "已完成",
"processing": "处理中",
"pending": "待处理",
"failed": "失败"
},
"errors": {
"loadFailed": "加载文档失败\n{{error}}",
"scanFailed": "扫描文档失败\n{{error}}",
"scanProgressFailed": "获取扫描进度失败\n{{error}}"
}
}
},
"graphPanel": {
"sideBar": {
"settings": {
"settings": "设置",
"healthCheck": "健康检查",
"showPropertyPanel": "显示属性面板",
"showSearchBar": "显示搜索栏",
"showNodeLabel": "显示节点标签",
"nodeDraggable": "节点可拖动",
"showEdgeLabel": "显示边标签",
"hideUnselectedEdges": "隐藏未选中边",
"edgeEvents": "边事件",
"maxQueryDepth": "最大查询深度",
"minDegree": "最小度数",
"maxLayoutIterations": "最大布局迭代次数",
"apiKey": "API 密钥",
"enterYourAPIkey": "输入您的 API 密钥",
"save": "保存"
},
"zoomControl": {
"zoomIn": "放大",
"zoomOut": "缩小",
"resetZoom": "重置缩放"
},
"layoutsControl": {
"startAnimation": "开始布局动画",
"stopAnimation": "停止布局动画",
"layoutGraph": "布局图",
"layouts": {
"Circular": "环形布局",
"Circlepack": "圆形打包布局",
"Random": "随机布局",
"Noverlaps": "无重叠布局",
"Force Directed": "力导向布局",
"Force Atlas": "力导向图谱布局"
}
},
"fullScreenControl": {
"fullScreen": "全屏",
"windowed": "窗口模式"
}
},
"statusIndicator": {
"connected": "已连接",
"disconnected": "未连接"
},
"statusCard": {
"unavailable": "状态信息不可用",
"storageInfo": "存储信息",
"workingDirectory": "工作目录",
"inputDirectory": "输入目录",
"llmConfig": "LLM 配置",
"llmBinding": "LLM 绑定",
"llmBindingHost": "LLM 绑定主机",
"llmModel": "LLM 模型",
"maxTokens": "最大 Token 数",
"embeddingConfig": "嵌入配置",
"embeddingBinding": "嵌入绑定",
"embeddingBindingHost": "嵌入绑定主机",
"embeddingModel": "嵌入模型",
"storageConfig": "存储配置",
"kvStorage": "KV 存储",
"docStatusStorage": "文档状态存储",
"graphStorage": "图存储",
"vectorStorage": "向量存储"
},
"propertiesView": {
"node": {
"title": "节点",
"id": "ID",
"labels": "标签",
"degree": "度数",
"properties": "属性",
"relationships": "关系"
},
"edge": {
"title": "关系",
"id": "ID",
"type": "类型",
"source": "源",
"target": "目标",
"properties": "属性"
}
},
"search": {
"placeholder": "搜索节点...",
"message": "以及其它 {count} 项"
},
"graphLabels": {
"selectTooltip": "选择查询标签",
"noLabels": "未找到标签",
"label": "标签",
"placeholder": "搜索标签...",
"andOthers": "以及其它 {count} 个"
}
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "复制到剪贴板",
"copyError": "无法复制文本到剪贴板"
},
"retrieval": {
"startPrompt": "在下面输入您的查询以开始检索",
"clear": "清除",
"send": "发送",
"placeholder": "输入您的查询...",
"error": "错误:无法获取响应"
},
"querySettings": {
"parametersTitle": "参数设置",
"parametersDescription": "配置查询参数",
"queryMode": "查询模式",
"queryModeTooltip": "选择检索策略:\n• 朴素:不使用高级技术的基本搜索\n• 本地:基于上下文的信息检索\n• 全局:利用全局知识库\n• 混合:结合本地和全局检索\n• 综合:集成知识图谱与向量检索",
"queryModeOptions": {
"naive": "朴素",
"local": "本地",
"global": "全局",
"hybrid": "混合",
"mix": "综合"
},
"responseFormat": "响应格式",
"responseFormatTooltip": "定义响应格式。例如:\n• 多个段落\n• 单个段落\n• 项目符号",
"responseFormatOptions": {
"multipleParagraphs": "多个段落",
"singleParagraph": "单个段落",
"bulletPoints": "项目符号"
},
"topK": "Top K 结果数",
"topKTooltip": "要检索的前 K 个项目数量。在“本地”模式下表示实体,在“全局”模式下表示关系",
"topKPlaceholder": "结果数",
"maxTokensTextUnit": "文本单元最大 Token 数",
"maxTokensTextUnitTooltip": "每个检索到的文本块允许的最大 Token 数",
"maxTokensGlobalContext": "全局上下文最大 Token 数",
"maxTokensGlobalContextTooltip": "在全局检索中为关系描述分配的最大 Token 数",
"maxTokensLocalContext": "本地上下文最大 Token 数",
"maxTokensLocalContextTooltip": "在本地检索中为实体描述分配的最大 Token 数",
"historyTurns": "历史轮次",
"historyTurnsTooltip": "在响应上下文中考虑的完整对话轮次(用户-助手对)",
"historyTurnsPlaceholder": "历史轮次的数量",
"hlKeywords": "高级关键词",
"hlKeywordsTooltip": "检索时优先考虑的高级关键词。请用逗号分隔",
"hlkeywordsPlaceHolder": "输入关键词",
"llKeywords": "低级关键词",
"llKeywordsTooltip": "用于优化检索焦点的低级关键词。请用逗号分隔",
"onlyNeedContext": "仅需要上下文",
"onlyNeedContextTooltip": "如果为 True则仅返回检索到的上下文而不会生成回复",
"onlyNeedPrompt": "仅需要提示",
"onlyNeedPromptTooltip": "如果为 True则仅返回生成的提示而不会生成回复",
"streamResponse": "流式响应",
"streamResponseTooltip": "如果为 True则启用流式输出以获得实时响应"
}
}
}

View File

@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import "./i18n";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>