Merge branch 'main' into fix--postgres-impl

This commit is contained in:
zrguo
2025-03-17 15:42:09 +08:00
committed by GitHub
51 changed files with 1584 additions and 787 deletions

View File

@@ -1,13 +1,23 @@
# Build stage # Build stage
FROM python:3.11-slim as builder FROM python:3.11-slim AS builder
WORKDIR /app WORKDIR /app
# Install Rust and required build dependencies
RUN apt-get update && apt-get install -y \
curl \
build-essential \
pkg-config \
&& rm -rf /var/lib/apt/lists/* \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
&& . $HOME/.cargo/env
# Copy only requirements files first to leverage Docker cache # Copy only requirements files first to leverage Docker cache
COPY requirements.txt . COPY requirements.txt .
COPY lightrag/api/requirements.txt ./lightrag/api/ COPY lightrag/api/requirements.txt ./lightrag/api/
# Install dependencies # Install dependencies
ENV PATH="/root/.cargo/bin:${PATH}"
RUN pip install --user --no-cache-dir -r requirements.txt RUN pip install --user --no-cache-dir -r requirements.txt
RUN pip install --user --no-cache-dir -r lightrag/api/requirements.txt RUN pip install --user --no-cache-dir -r lightrag/api/requirements.txt
@@ -28,6 +38,10 @@ ENV PATH=/root/.local/bin:$PATH
# Create necessary directories # Create necessary directories
RUN mkdir -p /app/data/rag_storage /app/data/inputs RUN mkdir -p /app/data/rag_storage /app/data/inputs
# Docker data directories
ENV WORKING_DIR=/app/data/rag_storage
ENV INPUT_DIR=/app/data/inputs
# Expose the default port # Expose the default port
EXPOSE 9621 EXPOSE 9621

0
README-zh.md Normal file
View File

View File

@@ -1061,7 +1061,7 @@ Valid modes are:
| **llm\_model\_func** | `callable` | Function for LLM generation | `gpt_4o_mini_complete` | | **llm\_model\_func** | `callable` | Function for LLM generation | `gpt_4o_mini_complete` |
| **llm\_model\_name** | `str` | LLM model name for generation | `meta-llama/Llama-3.2-1B-Instruct` | | **llm\_model\_name** | `str` | LLM model name for generation | `meta-llama/Llama-3.2-1B-Instruct` |
| **llm\_model\_max\_token\_size** | `int` | Maximum token size for LLM generation (affects entity relation summaries) | `32768`default value changed by env var MAX_TOKENS) | | **llm\_model\_max\_token\_size** | `int` | Maximum token size for LLM generation (affects entity relation summaries) | `32768`default value changed by env var MAX_TOKENS) |
| **llm\_model\_max\_async** | `int` | Maximum number of concurrent asynchronous LLM processes | `16`default value changed by env var MAX_ASYNC) | | **llm\_model\_max\_async** | `int` | Maximum number of concurrent asynchronous LLM processes | `4`default value changed by env var MAX_ASYNC) |
| **llm\_model\_kwargs** | `dict` | Additional parameters for LLM generation | | | **llm\_model\_kwargs** | `dict` | Additional parameters for LLM generation | |
| **vector\_db\_storage\_cls\_kwargs** | `dict` | Additional parameters for vector database, like setting the threshold for nodes and relations retrieval. | cosine_better_than_threshold: 0.2default value changed by env var COSINE_THRESHOLD) | | **vector\_db\_storage\_cls\_kwargs** | `dict` | Additional parameters for vector database, like setting the threshold for nodes and relations retrieval. | cosine_better_than_threshold: 0.2default value changed by env var COSINE_THRESHOLD) |
| **enable\_llm\_cache** | `bool` | If `TRUE`, stores LLM results in cache; repeated prompts return cached responses | `TRUE` | | **enable\_llm\_cache** | `bool` | If `TRUE`, stores LLM results in cache; repeated prompts return cached responses | `TRUE` |

View File

@@ -50,7 +50,8 @@
# MAX_TOKEN_SUMMARY=500 # Max tokens for entity or relations summary # MAX_TOKEN_SUMMARY=500 # Max tokens for entity or relations summary
# SUMMARY_LANGUAGE=English # SUMMARY_LANGUAGE=English
# MAX_EMBED_TOKENS=8192 # MAX_EMBED_TOKENS=8192
# ENABLE_LLM_CACHE_FOR_EXTRACT=false # Enable LLM cache for entity extraction, defaults to false # ENABLE_LLM_CACHE_FOR_EXTRACT=true # Enable LLM cache for entity extraction
# MAX_PARALLEL_INSERT=2 # Maximum number of parallel processing documents in pipeline
### LLM Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal) ### LLM Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)
LLM_BINDING=ollama LLM_BINDING=ollama

View File

@@ -224,7 +224,7 @@ LightRAG supports binding to various LLM/Embedding backends:
Use environment variables `LLM_BINDING` or CLI argument `--llm-binding` to select LLM backend type. Use environment variables `EMBEDDING_BINDING` or CLI argument `--embedding-binding` to select LLM backend type. Use environment variables `LLM_BINDING` or CLI argument `--llm-binding` to select LLM backend type. Use environment variables `EMBEDDING_BINDING` or CLI argument `--embedding-binding` to select LLM backend type.
### Entity Extraction Configuration ### Entity Extraction Configuration
* ENABLE_LLM_CACHE_FOR_EXTRACT: Enable LLM cache for entity extraction (default: false) * ENABLE_LLM_CACHE_FOR_EXTRACT: Enable LLM cache for entity extraction (default: true)
It's very common to set `ENABLE_LLM_CACHE_FOR_EXTRACT` to true for test environment to reduce the cost of LLM calls. It's very common to set `ENABLE_LLM_CACHE_FOR_EXTRACT` to true for test environment to reduce the cost of LLM calls.

View File

@@ -141,7 +141,7 @@ Start the LightRAG server using specified options:
lightrag-server --port 9621 --key sk-somepassword --kv-storage PGKVStorage --graph-storage PGGraphStorage --vector-storage PGVectorStorage --doc-status-storage PGDocStatusStorage lightrag-server --port 9621 --key sk-somepassword --kv-storage PGKVStorage --graph-storage PGGraphStorage --vector-storage PGVectorStorage --doc-status-storage PGDocStatusStorage
``` ```
Replace `the-port-number` with your desired port number (default is 9621) and `your-secret-key` with a secure key. Replace the `port` number with your desired port number (default is 9621) and `your-secret-key` with a secure key.
## Conclusion ## Conclusion

View File

@@ -391,12 +391,24 @@ def create_app(args):
"update_status": update_status, "update_status": update_status,
} }
# Custom StaticFiles class to prevent caching of HTML files
class NoCacheStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
if path.endswith(".html"):
response.headers["Cache-Control"] = (
"no-cache, no-store, must-revalidate"
)
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Webui mount webui/index.html # Webui mount webui/index.html
static_dir = Path(__file__).parent / "webui" static_dir = Path(__file__).parent / "webui"
static_dir.mkdir(exist_ok=True) static_dir.mkdir(exist_ok=True)
app.mount( app.mount(
"/webui", "/webui",
StaticFiles(directory=static_dir, html=True, check_dir=True), NoCacheStaticFiles(directory=static_dir, html=True, check_dir=True),
name="webui", name="webui",
) )

View File

@@ -3,6 +3,7 @@ ascii_colors
asyncpg asyncpg
distro distro
fastapi fastapi
graspologic>=3.4.1
httpcore httpcore
httpx httpx
jiter jiter

View File

@@ -364,7 +364,7 @@ def parse_args(is_uvicorn_mode: bool = False) -> argparse.Namespace:
# Inject LLM cache configuration # Inject LLM cache configuration
args.enable_llm_cache_for_extract = get_env_value( args.enable_llm_cache_for_extract = get_env_value(
"ENABLE_LLM_CACHE_FOR_EXTRACT", False, bool "ENABLE_LLM_CACHE_FOR_EXTRACT", True, bool
) )
# Select Document loading tool (DOCLING, DEFAULT) # Select Document loading tool (DOCLING, DEFAULT)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,11 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" type="image/svg+xml" href="./logo.png" /> <link rel="icon" type="image/svg+xml" href="./logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title> <title>Lightrag</title>
<script type="module" crossorigin src="./assets/index-BlVvSIic.js"></script> <script type="module" crossorigin src="./assets/index-DwcJE583.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CH-3l4_Z.css"> <link rel="stylesheet" crossorigin href="./assets/index-BV5s8k-a.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -432,19 +432,31 @@ class PGVectorStorage(BaseVectorStorage):
def _upsert_entities(self, item: dict[str, Any]) -> tuple[str, dict[str, Any]]: def _upsert_entities(self, item: dict[str, Any]) -> tuple[str, dict[str, Any]]:
upsert_sql = SQL_TEMPLATES["upsert_entity"] upsert_sql = SQL_TEMPLATES["upsert_entity"]
source_id = item["source_id"]
if isinstance(source_id, str) and "<SEP>" in source_id:
chunk_ids = source_id.split("<SEP>")
else:
chunk_ids = [source_id]
data: dict[str, Any] = { data: dict[str, Any] = {
"workspace": self.db.workspace, "workspace": self.db.workspace,
"id": item["__id__"], "id": item["__id__"],
"entity_name": item["entity_name"], "entity_name": item["entity_name"],
"content": item["content"], "content": item["content"],
"content_vector": json.dumps(item["__vector__"].tolist()), "content_vector": json.dumps(item["__vector__"].tolist()),
"chunk_id": item["source_id"], "chunk_ids": chunk_ids,
# TODO: add document_id # TODO: add document_id
} }
return upsert_sql, data return upsert_sql, data
def _upsert_relationships(self, item: dict[str, Any]) -> tuple[str, dict[str, Any]]: def _upsert_relationships(self, item: dict[str, Any]) -> tuple[str, dict[str, Any]]:
upsert_sql = SQL_TEMPLATES["upsert_relationship"] upsert_sql = SQL_TEMPLATES["upsert_relationship"]
source_id = item["source_id"]
if isinstance(source_id, str) and "<SEP>" in source_id:
chunk_ids = source_id.split("<SEP>")
else:
chunk_ids = [source_id]
data: dict[str, Any] = { data: dict[str, Any] = {
"workspace": self.db.workspace, "workspace": self.db.workspace,
"id": item["__id__"], "id": item["__id__"],
@@ -452,7 +464,7 @@ class PGVectorStorage(BaseVectorStorage):
"target_id": item["tgt_id"], "target_id": item["tgt_id"],
"content": item["content"], "content": item["content"],
"content_vector": json.dumps(item["__vector__"].tolist()), "content_vector": json.dumps(item["__vector__"].tolist()),
"chunk_id": item["source_id"], "chunk_ids": chunk_ids,
# TODO: add document_id # TODO: add document_id
} }
return upsert_sql, data return upsert_sql, data
@@ -755,7 +767,7 @@ class PGDocStatusStorage(DocStatusStorage):
result = await self.db.query(sql, params, True) result = await self.db.query(sql, params, True)
docs_by_status = { docs_by_status = {
element["id"]: DocProcessingStatus( element["id"]: DocProcessingStatus(
content=result[0]["content"], content=element["content"],
content_summary=element["content_summary"], content_summary=element["content_summary"],
content_length=element["content_length"], content_length=element["content_length"],
status=element["status"], status=element["status"],
@@ -1531,7 +1543,7 @@ TABLES = {
content_vector VECTOR, content_vector VECTOR,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP, update_time TIMESTAMP,
chunk_id VARCHAR(255) NULL, chunk_id TEXT NULL,
CONSTRAINT LIGHTRAG_VDB_ENTITY_PK PRIMARY KEY (workspace, id) CONSTRAINT LIGHTRAG_VDB_ENTITY_PK PRIMARY KEY (workspace, id)
)""" )"""
}, },
@@ -1545,7 +1557,7 @@ TABLES = {
content_vector VECTOR, content_vector VECTOR,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP, update_time TIMESTAMP,
chunk_id VARCHAR(255) NULL, chunk_id TEXT NULL,
CONSTRAINT LIGHTRAG_VDB_RELATION_PK PRIMARY KEY (workspace, id) CONSTRAINT LIGHTRAG_VDB_RELATION_PK PRIMARY KEY (workspace, id)
)""" )"""
}, },
@@ -1629,22 +1641,25 @@ SQL_TEMPLATES = {
update_time = CURRENT_TIMESTAMP update_time = CURRENT_TIMESTAMP
""", """,
"upsert_entity": """INSERT INTO LIGHTRAG_VDB_ENTITY (workspace, id, entity_name, content, "upsert_entity": """INSERT INTO LIGHTRAG_VDB_ENTITY (workspace, id, entity_name, content,
content_vector, chunk_id) content_vector, chunk_ids)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6::varchar[])
ON CONFLICT (workspace,id) DO UPDATE ON CONFLICT (workspace,id) DO UPDATE
SET entity_name=EXCLUDED.entity_name, SET entity_name=EXCLUDED.entity_name,
content=EXCLUDED.content, content=EXCLUDED.content,
content_vector=EXCLUDED.content_vector, content_vector=EXCLUDED.content_vector,
chunk_ids=EXCLUDED.chunk_ids,
update_time=CURRENT_TIMESTAMP update_time=CURRENT_TIMESTAMP
""", """,
"upsert_relationship": """INSERT INTO LIGHTRAG_VDB_RELATION (workspace, id, source_id, "upsert_relationship": """INSERT INTO LIGHTRAG_VDB_RELATION (workspace, id, source_id,
target_id, content, content_vector, chunk_id) target_id, content, content_vector, chunk_ids)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7::varchar[])
ON CONFLICT (workspace,id) DO UPDATE ON CONFLICT (workspace,id) DO UPDATE
SET source_id=EXCLUDED.source_id, SET source_id=EXCLUDED.source_id,
target_id=EXCLUDED.target_id, target_id=EXCLUDED.target_id,
content=EXCLUDED.content, content=EXCLUDED.content,
content_vector=EXCLUDED.content_vector, update_time = CURRENT_TIMESTAMP content_vector=EXCLUDED.content_vector,
chunk_ids=EXCLUDED.chunk_ids,
update_time = CURRENT_TIMESTAMP
""", """,
# SQL for VectorStorage # SQL for VectorStorage
# "entities": """SELECT entity_name FROM # "entities": """SELECT entity_name FROM
@@ -1695,8 +1710,8 @@ SQL_TEMPLATES = {
FROM ( FROM (
SELECT r.id, r.source_id, r.target_id, 1 - (r.content_vector <=> '[{embedding_string}]'::vector) as distance SELECT r.id, r.source_id, r.target_id, 1 - (r.content_vector <=> '[{embedding_string}]'::vector) as distance
FROM LIGHTRAG_VDB_RELATION r FROM LIGHTRAG_VDB_RELATION r
JOIN relevant_chunks c ON c.chunk_id = ANY(r.chunk_ids)
WHERE r.workspace=$1 WHERE r.workspace=$1
AND r.chunk_id IN (SELECT chunk_id FROM relevant_chunks)
) filtered ) filtered
WHERE distance>$2 WHERE distance>$2
ORDER BY distance DESC ORDER BY distance DESC
@@ -1710,10 +1725,10 @@ SQL_TEMPLATES = {
) )
SELECT entity_name FROM SELECT entity_name FROM
( (
SELECT id, entity_name, 1 - (content_vector <=> '[{embedding_string}]'::vector) as distance SELECT e.id, e.entity_name, 1 - (e.content_vector <=> '[{embedding_string}]'::vector) as distance
FROM LIGHTRAG_VDB_ENTITY FROM LIGHTRAG_VDB_ENTITY e
where workspace=$1 JOIN relevant_chunks c ON c.chunk_id = ANY(e.chunk_ids)
AND chunk_id IN (SELECT chunk_id FROM relevant_chunks) WHERE e.workspace=$1
) )
WHERE distance>$2 WHERE distance>$2
ORDER BY distance DESC ORDER BY distance DESC

View File

@@ -214,7 +214,7 @@ class LightRAG:
llm_model_max_token_size: int = field(default=int(os.getenv("MAX_TOKENS", 32768))) llm_model_max_token_size: int = field(default=int(os.getenv("MAX_TOKENS", 32768)))
"""Maximum number of tokens allowed per LLM response.""" """Maximum number of tokens allowed per LLM response."""
llm_model_max_async: int = field(default=int(os.getenv("MAX_ASYNC", 16))) llm_model_max_async: int = field(default=int(os.getenv("MAX_ASYNC", 4)))
"""Maximum number of concurrent LLM calls.""" """Maximum number of concurrent LLM calls."""
llm_model_kwargs: dict[str, Any] = field(default_factory=dict) llm_model_kwargs: dict[str, Any] = field(default_factory=dict)
@@ -238,7 +238,7 @@ class LightRAG:
# Extensions # Extensions
# --- # ---
max_parallel_insert: int = field(default=int(os.getenv("MAX_PARALLEL_INSERT", 20))) max_parallel_insert: int = field(default=int(os.getenv("MAX_PARALLEL_INSERT", 2)))
"""Maximum number of parallel insert operations.""" """Maximum number of parallel insert operations."""
addon_params: dict[str, Any] = field( addon_params: dict[str, Any] = field(
@@ -553,6 +553,7 @@ class LightRAG:
Args: Args:
input: Single document string or list of document strings input: Single document string or list of document strings
split_by_character: if split_by_character is not None, split the string by character, if chunk longer than split_by_character: if split_by_character is not None, split the string by character, if chunk longer than
chunk_token_size, it will be split again by token size.
split_by_character_only: if split_by_character_only is True, split the string by character only, when split_by_character_only: if split_by_character_only is True, split the string by character only, when
split_by_character is None, this parameter is ignored. split_by_character is None, this parameter is ignored.
ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated
@@ -574,6 +575,7 @@ class LightRAG:
Args: Args:
input: Single document string or list of document strings input: Single document string or list of document strings
split_by_character: if split_by_character is not None, split the string by character, if chunk longer than split_by_character: if split_by_character is not None, split the string by character, if chunk longer than
chunk_token_size, it will be split again by token size.
split_by_character_only: if split_by_character_only is True, split the string by character only, when split_by_character_only: if split_by_character_only is True, split the string by character only, when
split_by_character is None, this parameter is ignored. split_by_character is None, this parameter is ignored.
ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated
@@ -767,7 +769,6 @@ class LightRAG:
async with pipeline_status_lock: async with pipeline_status_lock:
# Ensure only one worker is processing documents # Ensure only one worker is processing documents
if not pipeline_status.get("busy", False): if not pipeline_status.get("busy", False):
# 先检查是否有需要处理的文档
processing_docs, failed_docs, pending_docs = await asyncio.gather( processing_docs, failed_docs, pending_docs = await asyncio.gather(
self.doc_status.get_docs_by_status(DocStatus.PROCESSING), self.doc_status.get_docs_by_status(DocStatus.PROCESSING),
self.doc_status.get_docs_by_status(DocStatus.FAILED), self.doc_status.get_docs_by_status(DocStatus.FAILED),
@@ -779,12 +780,10 @@ class LightRAG:
to_process_docs.update(failed_docs) to_process_docs.update(failed_docs)
to_process_docs.update(pending_docs) to_process_docs.update(pending_docs)
# 如果没有需要处理的文档,直接返回,保留 pipeline_status 中的内容不变
if not to_process_docs: if not to_process_docs:
logger.info("No documents to process") logger.info("No documents to process")
return return
# 有文档需要处理,更新 pipeline_status
pipeline_status.update( pipeline_status.update(
{ {
"busy": True, "busy": True,
@@ -823,7 +822,7 @@ class LightRAG:
for i in range(0, len(to_process_docs), self.max_parallel_insert) for i in range(0, len(to_process_docs), self.max_parallel_insert)
] ]
log_message = f"Number of batches to process: {len(docs_batches)}." log_message = f"Processing {len(to_process_docs)} document(s) in {len(docs_batches)} batches"
logger.info(log_message) logger.info(log_message)
# Update pipeline status with current batch information # Update pipeline status with current batch information
@@ -832,26 +831,16 @@ 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)
batches: list[Any] = [] async def process_document(
# 3. iterate over batches doc_id: str,
for batch_idx, docs_batch in enumerate(docs_batches): status_doc: DocProcessingStatus,
# Update current batch in pipeline status (directly, as it's atomic) split_by_character: str | None,
pipeline_status["cur_batch"] += 1 split_by_character_only: bool,
pipeline_status: dict,
async def batch( pipeline_status_lock: asyncio.Lock,
batch_idx: int,
docs_batch: list[tuple[str, DocProcessingStatus]],
size_batch: int,
) -> None: ) -> None:
log_message = ( """Process single document"""
f"Start processing batch {batch_idx + 1} of {size_batch}." try:
)
logger.info(log_message)
pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message)
# 4. iterate over batch
for doc_id_processing_status in docs_batch:
doc_id, status_doc = doc_id_processing_status
# Generate chunks from document # Generate chunks from document
chunks: dict[str, Any] = { chunks: dict[str, Any] = {
compute_mdhash_id(dp["content"], prefix="chunk-"): { compute_mdhash_id(dp["content"], prefix="chunk-"): {
@@ -906,7 +895,6 @@ class LightRAG:
full_docs_task, full_docs_task,
text_chunks_task, text_chunks_task,
] ]
try:
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
await self.doc_status.upsert( await self.doc_status.upsert(
{ {
@@ -923,10 +911,9 @@ class LightRAG:
) )
except Exception as e: except Exception as e:
# Log error and update pipeline status # Log error and update pipeline status
error_msg = ( error_msg = f"Failed to process document {doc_id}: {str(e)}"
f"Failed to process document {doc_id}: {str(e)}"
)
logger.error(error_msg) logger.error(error_msg)
async with pipeline_status_lock:
pipeline_status["latest_message"] = error_msg pipeline_status["latest_message"] = error_msg
pipeline_status["history_messages"].append(error_msg) pipeline_status["history_messages"].append(error_msg)
@@ -939,7 +926,6 @@ class LightRAG:
]: ]:
if not task.done(): if not task.done():
task.cancel() task.cancel()
# Update document status to failed # Update document status to failed
await self.doc_status.upsert( await self.doc_status.upsert(
{ {
@@ -954,19 +940,41 @@ class LightRAG:
} }
} }
) )
continue
# 3. iterate over batches
total_batches = len(docs_batches)
for batch_idx, docs_batch in enumerate(docs_batches):
current_batch = batch_idx + 1
log_message = ( log_message = (
f"Completed batch {batch_idx + 1} of {len(docs_batches)}." f"Start processing batch {current_batch} of {total_batches}."
) )
logger.info(log_message) logger.info(log_message)
pipeline_status["cur_batch"] = current_batch
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)
batches.append(batch(batch_idx, docs_batch, len(docs_batches))) doc_tasks = []
for doc_id, status_doc in docs_batch:
doc_tasks.append(
process_document(
doc_id,
status_doc,
split_by_character,
split_by_character_only,
pipeline_status,
pipeline_status_lock,
)
)
await asyncio.gather(*batches) # Process documents in one batch parallelly
await asyncio.gather(*doc_tasks)
await self._insert_done() await self._insert_done()
log_message = f"Completed batch {current_batch} of {total_batches}."
logger.info(log_message)
pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message)
# Check if there's a pending request to process more documents (with lock) # Check if there's a pending request to process more documents (with lock)
has_pending_request = False has_pending_request = False
async with pipeline_status_lock: async with pipeline_status_lock:
@@ -1040,7 +1048,7 @@ class LightRAG:
] ]
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
log_message = "All Insert done" log_message = "In memory DB persist to disk"
logger.info(log_message) logger.info(log_message)
if pipeline_status is not None and pipeline_status_lock is not None: if pipeline_status is not None and pipeline_status_lock is not None:

View File

@@ -62,6 +62,7 @@
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-i18next": "^8.1.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@vitejs/plugin-react-swc": "^3.8.0", "@vitejs/plugin-react-swc": "^3.8.0",
@@ -443,6 +444,8 @@
"@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="], "@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="],
"@types/react-i18next": ["@types/react-i18next@8.1.0", "", { "dependencies": { "react-i18next": "*" } }, "sha512-d4xhcjX5b3roNMObRNMfb1HinHQlQLPo8xlDj60dnHeeAw2bBymR2cy/l1giJpHzo/ZFgSvgVUvIWr4kCrenCg=="],
"@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="],
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],

View File

@@ -2,6 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" type="image/svg+xml" href="/logo.png" /> <link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title> <title>Lightrag</title>

View File

@@ -71,6 +71,7 @@
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-i18next": "^8.1.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@vitejs/plugin-react-swc": "^3.8.0", "@vitejs/plugin-react-swc": "^3.8.0",

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import ThemeProvider from '@/components/ThemeProvider' import ThemeProvider from '@/components/ThemeProvider'
import TabVisibilityProvider from '@/contexts/TabVisibilityProvider'
import MessageAlert from '@/components/MessageAlert' import MessageAlert from '@/components/MessageAlert'
import ApiKeyAlert from '@/components/ApiKeyAlert' import ApiKeyAlert from '@/components/ApiKeyAlert'
import StatusIndicator from '@/components/graph/StatusIndicator' import StatusIndicator from '@/components/graph/StatusIndicator'
@@ -21,7 +22,7 @@ import { Tabs, TabsContent } from '@/components/ui/Tabs'
function App() { function App() {
const message = useBackendState.use.message() const message = useBackendState.use.message()
const enableHealthCheck = useSettingsStore.use.enableHealthCheck() const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
const [currentTab] = useState(() => useSettingsStore.getState().currentTab) const currentTab = useSettingsStore.use.currentTab()
const [apiKeyInvalid, setApiKeyInvalid] = useState(false) const [apiKeyInvalid, setApiKeyInvalid] = useState(false)
// Health check // Health check
@@ -54,6 +55,7 @@ function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<TabVisibilityProvider>
<main className="flex h-screen w-screen overflow-x-hidden"> <main className="flex h-screen w-screen overflow-x-hidden">
<Tabs <Tabs
defaultValue={currentTab} defaultValue={currentTab}
@@ -81,6 +83,7 @@ function App() {
{apiKeyInvalid && <ApiKeyAlert />} {apiKeyInvalid && <ApiKeyAlert />}
<Toaster /> <Toaster />
</main> </main>
</TabVisibilityProvider>
</ThemeProvider> </ThemeProvider>
) )
} }

View File

@@ -0,0 +1,66 @@
import { useState, useCallback } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Button from '@/components/ui/Button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
import { useSettingsStore } from '@/stores/settings'
import { PaletteIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function AppSettings() {
const [opened, setOpened] = useState<boolean>(false)
const { t } = useTranslation()
const language = useSettingsStore.use.language()
const setLanguage = useSettingsStore.use.setLanguage()
const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
const handleLanguageChange = useCallback((value: string) => {
setLanguage(value as 'en' | 'zh')
}, [setLanguage])
const handleThemeChange = useCallback((value: string) => {
setTheme(value as 'light' | 'dark' | 'system')
}, [setTheme])
return (
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<PaletteIcon className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.language')}</label>
<Select value={language} onValueChange={handleLanguageChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.theme')}</label>
<Select value={theme} onValueChange={handleThemeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t('settings.light')}</SelectItem>
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
<SelectItem value="system">{t('settings.system')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,24 @@
import { StrictMode, useEffect, useState } from 'react'
import { initializeI18n } from '@/i18n'
import App from '@/App'
export const Root = () => {
const [isI18nInitialized, setIsI18nInitialized] = useState(false)
useEffect(() => {
// Initialize i18n immediately with persisted language
initializeI18n().then(() => {
setIsI18nInitialized(true)
})
}, [])
if (!isI18nInitialized) {
return null // or a loading spinner
}
return (
<StrictMode>
<App />
</StrictMode>
)
}

View File

@@ -1,4 +1,4 @@
import { createContext, useEffect, useState } from 'react' import { createContext, useEffect } from 'react'
import { Theme, useSettingsStore } from '@/stores/settings' import { Theme, useSettingsStore } from '@/stores/settings'
type ThemeProviderProps = { type ThemeProviderProps = {
@@ -21,30 +21,32 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
* Component that provides the theme state and setter function to its children. * Component that provides the theme state and setter function to its children.
*/ */
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) { export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(useSettingsStore.getState().theme) const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
useEffect(() => { useEffect(() => {
const root = window.document.documentElement const root = window.document.documentElement
root.classList.remove('light', 'dark') root.classList.remove('light', 'dark')
if (theme === 'system') { if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
? 'dark' const handleChange = (e: MediaQueryListEvent) => {
: 'light' root.classList.remove('light', 'dark')
root.classList.add(systemTheme) root.classList.add(e.matches ? 'dark' : 'light')
setTheme(systemTheme)
return
} }
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
} else {
root.classList.add(theme) root.classList.add(theme)
}
}, [theme]) }, [theme])
const value = { const value = {
theme, theme,
setTheme: (theme: Theme) => { setTheme
useSettingsStore.getState().setTheme(theme)
setTheme(theme)
}
} }
return ( return (

View File

@@ -13,16 +13,25 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
* When the selected item changes, highlighted the node and center the camera on it. * When the selected item changes, highlighted the node and center the camera on it.
*/ */
useEffect(() => { useEffect(() => {
if (!node) return
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
if (move) { if (move) {
if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
gotoNode(node) gotoNode(node)
} else {
// If no node is selected but move is true, reset to default view
sigma.setCustomBBox(null)
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 })
}
useGraphStore.getState().setMoveToSelectedNode(false) useGraphStore.getState().setMoveToSelectedNode(false)
} else if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
} }
return () => { return () => {
if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', false) sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
} }
}
}, [node, move, sigma, gotoNode]) }, [node, move, sigma, gotoNode])
return null return null

View File

@@ -1,10 +1,11 @@
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core' import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import Graph from 'graphology'
// import { useLayoutCircular } from '@react-sigma/layout-circular' // import { useLayoutCircular } from '@react-sigma/layout-circular'
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2' import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useEffect } from 'react' import { useEffect } from 'react'
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph' // import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
import useLightragGraph, { EdgeType, NodeType } from '@/hooks/useLightragGraph' import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import useTheme from '@/hooks/useTheme' import useTheme from '@/hooks/useTheme'
import * as Constants from '@/lib/constants' import * as Constants from '@/lib/constants'
@@ -21,7 +22,6 @@ const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
} }
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => { const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
const { lightrageGraph } = useLightragGraph()
const sigma = useSigma<NodeType, EdgeType>() const sigma = useSigma<NodeType, EdgeType>()
const registerEvents = useRegisterEvents<NodeType, EdgeType>() const registerEvents = useRegisterEvents<NodeType, EdgeType>()
const setSettings = useSetSettings<NodeType, EdgeType>() const setSettings = useSetSettings<NodeType, EdgeType>()
@@ -34,21 +34,25 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const { theme } = useTheme() const { theme } = useTheme()
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges() const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
const renderLabels = useSettingsStore.use.showNodeLabel()
const selectedNode = useGraphStore.use.selectedNode() const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode() const focusedNode = useGraphStore.use.focusedNode()
const selectedEdge = useGraphStore.use.selectedEdge() const selectedEdge = useGraphStore.use.selectedEdge()
const focusedEdge = useGraphStore.use.focusedEdge() const focusedEdge = useGraphStore.use.focusedEdge()
const sigmaGraph = useGraphStore.use.sigmaGraph()
/** /**
* When component mount or maxIterations changes * When component mount or maxIterations changes
* => load the graph and apply layout * => load the graph and apply layout
*/ */
useEffect(() => { useEffect(() => {
// Create & load the graph if (sigmaGraph) {
const graph = lightrageGraph() loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>)
loadGraph(graph)
assignLayout() assignLayout()
}, [assignLayout, loadGraph, lightrageGraph, maxIterations]) }
}, [assignLayout, loadGraph, sigmaGraph, maxIterations])
/** /**
* When component mount * When component mount
@@ -58,39 +62,52 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } = const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
useGraphStore.getState() useGraphStore.getState()
// Register the events // Define event types
registerEvents({ type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }
enterNode: (event) => { type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }
// Register all events, but edge events will only be processed if enableEdgeEvents is true
const events: Record<string, any> = {
enterNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) { if (!isButtonPressed(event.event.original)) {
setFocusedNode(event.node) setFocusedNode(event.node)
} }
}, },
leaveNode: (event) => { leaveNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) { if (!isButtonPressed(event.event.original)) {
setFocusedNode(null) setFocusedNode(null)
} }
}, },
clickNode: (event) => { clickNode: (event: NodeEvent) => {
setSelectedNode(event.node) setSelectedNode(event.node)
setSelectedEdge(null) setSelectedEdge(null)
}, },
clickEdge: (event) => { clickStage: () => clearSelection()
}
// Only add edge event handlers if enableEdgeEvents is true
if (enableEdgeEvents) {
events.clickEdge = (event: EdgeEvent) => {
setSelectedEdge(event.edge) setSelectedEdge(event.edge)
setSelectedNode(null) setSelectedNode(null)
}, }
enterEdge: (event) => {
events.enterEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) { if (!isButtonPressed(event.event.original)) {
setFocusedEdge(event.edge) setFocusedEdge(event.edge)
} }
}, }
leaveEdge: (event) => {
events.leaveEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) { if (!isButtonPressed(event.event.original)) {
setFocusedEdge(null) setFocusedEdge(null)
} }
}, }
clickStage: () => clearSelection() }
})
}, [registerEvents]) // Register the events
registerEvents(events)
}, [registerEvents, enableEdgeEvents])
/** /**
* When component mount or hovered node change * When component mount or hovered node change
@@ -101,7 +118,14 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
// Update all dynamic settings directly without recreating the sigma container
setSettings({ setSettings({
// Update display settings
enableEdgeEvents,
renderEdgeLabels,
renderLabels,
// Node reducer for node appearance
nodeReducer: (node, data) => { nodeReducer: (node, data) => {
const graph = sigma.getGraph() const graph = sigma.getGraph()
const newData: NodeType & { const newData: NodeType & {
@@ -140,6 +164,8 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
} }
return newData return newData
}, },
// Edge reducer for edge appearance
edgeReducer: (edge, data) => { edgeReducer: (edge, data) => {
const graph = sigma.getGraph() const graph = sigma.getGraph()
const newData = { ...data, hidden: false, labelColor, color: edgeColor } const newData = { ...data, hidden: false, labelColor, color: edgeColor }
@@ -181,7 +207,10 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
sigma, sigma,
disableHoverEffect, disableHoverEffect,
theme, theme,
hideUnselectedEdges hideUnselectedEdges,
enableEdgeEvents,
renderEdgeLabels,
renderLabels
]) ])
return null return null

View File

@@ -1,37 +1,48 @@
import { useCallback } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect' import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { getGraphLabels } from '@/api/lightrag'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { labelListLimit } from '@/lib/constants' import { labelListLimit } from '@/lib/constants'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const lastGraph: any = {
graph: null,
searchEngine: null,
labels: []
}
const GraphLabels = () => { const GraphLabels = () => {
const { t } = useTranslation() const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel() const label = useSettingsStore.use.queryLabel()
const graph = useGraphStore.use.sigmaGraph() const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
const labelsLoadedRef = useRef(false)
const getSearchEngine = useCallback(async () => { // Track if a fetch is in progress to prevent multiple simultaneous fetches
if (lastGraph.graph == graph) { const fetchInProgressRef = useRef(false)
return {
labels: lastGraph.labels,
searchEngine: lastGraph.searchEngine
}
}
const labels = ['*'].concat(await getGraphLabels())
// Ensure query label exists // Fetch labels once on component mount, using global flag to prevent duplicates
if (!labels.includes(useSettingsStore.getState().queryLabel)) { useEffect(() => {
useSettingsStore.getState().setQueryLabel(labels[0]) // Check if we've already attempted to fetch labels in this session
} const labelsFetchAttempted = useGraphStore.getState().labelsFetchAttempted
// Only fetch if we haven't attempted in this session and no fetch is in progress
if (!labelsFetchAttempted && !fetchInProgressRef.current) {
fetchInProgressRef.current = true
// Set global flag to indicate we've attempted to fetch in this session
useGraphStore.getState().setLabelsFetchAttempted(true)
console.log('Fetching graph labels (once per session)...')
useGraphStore.getState().fetchAllDatabaseLabels()
.then(() => {
labelsLoadedRef.current = true
fetchInProgressRef.current = false
})
.catch((error) => {
console.error('Failed to fetch labels:', error)
fetchInProgressRef.current = false
// Reset global flag to allow retry
useGraphStore.getState().setLabelsFetchAttempted(false)
})
}
}, []) // Empty dependency array ensures this only runs once on mount
const getSearchEngine = useCallback(() => {
// Create search engine // Create search engine
const searchEngine = new MiniSearch({ const searchEngine = new MiniSearch({
idField: 'id', idField: 'id',
@@ -46,41 +57,32 @@ const GraphLabels = () => {
}) })
// Add documents // Add documents
const documents = labels.map((str, index) => ({ id: index, value: str })) const documents = allDatabaseLabels.map((str, index) => ({ id: index, value: str }))
searchEngine.addAll(documents) searchEngine.addAll(documents)
lastGraph.graph = graph
lastGraph.searchEngine = searchEngine
lastGraph.labels = labels
return { return {
labels, labels: allDatabaseLabels,
searchEngine searchEngine
} }
}, [graph]) }, [allDatabaseLabels])
const fetchData = useCallback( const fetchData = useCallback(
async (query?: string): Promise<string[]> => { async (query?: string): Promise<string[]> => {
const { labels, searchEngine } = await getSearchEngine() const { labels, searchEngine } = getSearchEngine()
let result: string[] = labels let result: string[] = labels
if (query) { if (query) {
// Search labels // Search labels
result = searchEngine.search(query).map((r) => labels[r.id]) result = searchEngine.search(query).map((r: { id: number }) => labels[r.id])
} }
return result.length <= labelListLimit return result.length <= labelListLimit
? result ? result
: [...result.slice(0, labelListLimit), t('graphLabels.andOthers', { count: result.length - labelListLimit })] : [...result.slice(0, labelListLimit), '...']
}, },
[getSearchEngine] [getSearchEngine]
) )
const setQueryLabel = useCallback((label: string) => {
if (label.startsWith('And ') && label.endsWith(' others')) return
useSettingsStore.getState().setQueryLabel(label)
}, [])
return ( return (
<AsyncSelect<string> <AsyncSelect<string>
className="ml-2" className="ml-2"
@@ -94,8 +96,38 @@ const GraphLabels = () => {
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={t('graphPanel.graphLabels.label')} label={t('graphPanel.graphLabels.label')}
placeholder={t('graphPanel.graphLabels.placeholder')} placeholder={t('graphPanel.graphLabels.placeholder')}
value={label !== null ? label : ''} value={label !== null ? label : '*'}
onChange={setQueryLabel} onChange={(newLabel) => {
const currentLabel = useSettingsStore.getState().queryLabel
// select the last item means query all
if (newLabel === '...') {
newLabel = '*'
}
// Reset the fetch attempted flag to force a new data fetch
useGraphStore.getState().setGraphDataFetchAttempted(false)
// Clear current graph data to ensure complete reload when label changes
if (newLabel !== currentLabel) {
const graphStore = useGraphStore.getState();
graphStore.clearSelection();
// Reset the graph state but preserve the instance
if (graphStore.sigmaGraph) {
const nodes = Array.from(graphStore.sigmaGraph.nodes());
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
}
}
if (newLabel === currentLabel && newLabel !== '*') {
// reselect the same itme means qery all
useSettingsStore.getState().setQueryLabel('*')
} else {
useSettingsStore.getState().setQueryLabel(newLabel)
}
}}
clearable={false} // Prevent clearing value on reselect
/> />
) )
} }

View File

@@ -1,4 +1,4 @@
import { FC, useCallback, useMemo } from 'react' import { FC, useCallback, useEffect, useMemo } from 'react'
import { import {
EdgeById, EdgeById,
NodeById, NodeById,
@@ -28,6 +28,7 @@ function OptionComponent(item: OptionItem) {
} }
const messageId = '__message_item' const messageId = '__message_item'
// Reset this cache when graph changes to ensure fresh search results
const lastGraph: any = { const lastGraph: any = {
graph: null, graph: null,
searchEngine: null searchEngine: null
@@ -48,6 +49,15 @@ export const GraphSearchInput = ({
const { t } = useTranslation() const { t } = useTranslation()
const graph = useGraphStore.use.sigmaGraph() const graph = useGraphStore.use.sigmaGraph()
// Force reset the cache when graph changes
useEffect(() => {
if (graph) {
// Reset cache to ensure fresh search results with new graph data
lastGraph.graph = null;
lastGraph.searchEngine = null;
}
}, [graph]);
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
if (lastGraph.graph == graph) { if (lastGraph.graph == graph) {
return lastGraph.searchEngine return lastGraph.searchEngine
@@ -85,8 +95,19 @@ export const GraphSearchInput = ({
const loadOptions = useCallback( const loadOptions = useCallback(
async (query?: string): Promise<OptionItem[]> => { async (query?: string): Promise<OptionItem[]> => {
if (onFocus) onFocus(null) if (onFocus) onFocus(null)
if (!query || !searchEngine) return [] if (!graph || !searchEngine) return []
const result: OptionItem[] = searchEngine.search(query).map((r) => ({
// If no query, return first searchResultLimit nodes
if (!query) {
const nodeIds = graph.nodes().slice(0, searchResultLimit)
return nodeIds.map(id => ({
id,
type: 'nodes'
}))
}
// If has query, search nodes
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({
id: r.id, id: r.id,
type: 'nodes' type: 'nodes'
})) }))
@@ -103,7 +124,7 @@ export const GraphSearchInput = ({
} }
] ]
}, },
[searchEngine, onFocus] [graph, searchEngine, onFocus, t]
) )
return ( return (

View File

@@ -132,14 +132,22 @@ const PropertyRow = ({
onClick?: () => void onClick?: () => void
tooltip?: string tooltip?: string
}) => { }) => {
const { t } = useTranslation()
const getPropertyNameTranslation = (name: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
const translation = t(translationKey)
return translation === translationKey ? name : translation
}
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-primary/60 tracking-wide">{name}</label>: <label className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</label>:
<Text <Text
className="hover:bg-primary/20 rounded p-1 text-ellipsis" className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis"
tooltipClassName="max-w-80" tooltipClassName="max-w-80"
text={value} text={value}
tooltip={tooltip || value} tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
side="left" side="left"
onClick={onClick} onClick={onClick}
/> />

View File

@@ -8,9 +8,10 @@ import Input from '@/components/ui/Input'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state' import { useBackendState } from '@/stores/state'
import { useGraphStore } from '@/stores/graph'
import { SettingsIcon } from 'lucide-react' import { SettingsIcon, RefreshCwIcon } from 'lucide-react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
/** /**
* Component that displays a checkbox with a label. * Component that displays a checkbox with a label.
@@ -114,6 +115,7 @@ const LabeledNumberInput = ({
export default function Settings() { export default function Settings() {
const [opened, setOpened] = useState<boolean>(false) const [opened, setOpened] = useState<boolean>(false)
const [tempApiKey, setTempApiKey] = useState<string>('') const [tempApiKey, setTempApiKey] = useState<string>('')
const refreshLayout = useGraphStore.use.refreshLayout()
const showPropertyPanel = useSettingsStore.use.showPropertyPanel() const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar() const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
@@ -208,9 +210,18 @@ export default function Settings() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<>
<Button
variant={controlButtonVariant}
tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
size="icon"
onClick={refreshLayout}
>
<RefreshCwIcon />
</Button>
<Popover open={opened} onOpenChange={setOpened}> <Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant={controlButtonVariant} tooltip={t("graphPanel.sideBar.settings.settings")} size="icon"> <Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon">
<SettingsIcon /> <SettingsIcon />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -224,7 +235,7 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={enableHealthCheck} checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck} onCheckedChange={setEnableHealthCheck}
label={t("graphPanel.sideBar.settings.healthCheck")} label={t('graphPanel.sideBar.settings.healthCheck')}
/> />
<Separator /> <Separator />
@@ -232,12 +243,12 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showPropertyPanel} checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel} onCheckedChange={setShowPropertyPanel}
label={t("graphPanel.sideBar.settings.showPropertyPanel")} label={t('graphPanel.sideBar.settings.showPropertyPanel')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={showNodeSearchBar} checked={showNodeSearchBar}
onCheckedChange={setShowNodeSearchBar} onCheckedChange={setShowNodeSearchBar}
label={t("graphPanel.sideBar.settings.showSearchBar")} label={t('graphPanel.sideBar.settings.showSearchBar')}
/> />
<Separator /> <Separator />
@@ -245,12 +256,12 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showNodeLabel} checked={showNodeLabel}
onCheckedChange={setShowNodeLabel} onCheckedChange={setShowNodeLabel}
label={t("graphPanel.sideBar.settings.showNodeLabel")} label={t('graphPanel.sideBar.settings.showNodeLabel')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableNodeDrag} checked={enableNodeDrag}
onCheckedChange={setEnableNodeDrag} onCheckedChange={setEnableNodeDrag}
label={t("graphPanel.sideBar.settings.nodeDraggable")} label={t('graphPanel.sideBar.settings.nodeDraggable')}
/> />
<Separator /> <Separator />
@@ -258,50 +269,50 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showEdgeLabel} checked={showEdgeLabel}
onCheckedChange={setShowEdgeLabel} onCheckedChange={setShowEdgeLabel}
label={t("graphPanel.sideBar.settings.showEdgeLabel")} label={t('graphPanel.sideBar.settings.showEdgeLabel')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableHideUnselectedEdges} checked={enableHideUnselectedEdges}
onCheckedChange={setEnableHideUnselectedEdges} onCheckedChange={setEnableHideUnselectedEdges}
label={t("graphPanel.sideBar.settings.hideUnselectedEdges")} label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableEdgeEvents} checked={enableEdgeEvents}
onCheckedChange={setEnableEdgeEvents} onCheckedChange={setEnableEdgeEvents}
label={t("graphPanel.sideBar.settings.edgeEvents")} label={t('graphPanel.sideBar.settings.edgeEvents')}
/> />
<Separator /> <Separator />
<LabeledNumberInput <LabeledNumberInput
label={t("graphPanel.sideBar.settings.maxQueryDepth")} label={t('graphPanel.sideBar.settings.maxQueryDepth')}
min={1} min={1}
value={graphQueryMaxDepth} value={graphQueryMaxDepth}
onEditFinished={setGraphQueryMaxDepth} onEditFinished={setGraphQueryMaxDepth}
/> />
<LabeledNumberInput <LabeledNumberInput
label={t("graphPanel.sideBar.settings.minDegree")} label={t('graphPanel.sideBar.settings.minDegree')}
min={0} min={0}
value={graphMinDegree} value={graphMinDegree}
onEditFinished={setGraphMinDegree} onEditFinished={setGraphMinDegree}
/> />
<LabeledNumberInput <LabeledNumberInput
label={t("graphPanel.sideBar.settings.maxLayoutIterations")} label={t('graphPanel.sideBar.settings.maxLayoutIterations')}
min={1} min={1}
max={20} max={30}
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">{t("graphPanel.sideBar.settings.apiKey")}</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={t("graphPanel.sideBar.settings.enterYourAPIkey")} 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"
/> />
@@ -312,12 +323,13 @@ export default function Settings() {
size="sm" size="sm"
className="max-h-full shrink-0" className="max-h-full shrink-0"
> >
{t("graphPanel.sideBar.settings.save")} {t('graphPanel.sideBar.settings.save')}
</Button> </Button>
</form> </form>
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</>
) )
} }

View File

@@ -0,0 +1,21 @@
import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
/**
* Component that displays current values of important graph settings
* Positioned to the right of the toolbar at the bottom-left corner
*/
const SettingsDisplay = () => {
const { t } = useTranslation()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
const graphMinDegree = useSettingsStore.use.graphMinDegree()
return (
<div className="absolute bottom-2 left-[calc(2rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
<div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
</div>
)
}
export default SettingsDisplay

View File

@@ -25,7 +25,7 @@ export default function QuerySettings() {
}, []) }, [])
return ( return (
<Card className="flex shrink-0 flex-col"> <Card className="flex shrink-0 flex-col min-w-[180px]">
<CardHeader className="px-4 pt-4 pb-2"> <CardHeader className="px-4 pt-4 pb-2">
<CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle> <CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
<CardDescription>{t('retrievePanel.querySettings.parametersDescription')}</CardDescription> <CardDescription>{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useDebounce } from '@/hooks/useDebounce' import { useDebounce } from '@/hooks/useDebounce'
@@ -193,7 +193,7 @@ export function AsyncSearch<T>({
</div> </div>
)} )}
</div> </div>
<CommandList hidden={!open || debouncedSearchTerm.length === 0}> <CommandList hidden={!open}>
{error && <div className="text-destructive p-4 text-center">{error}</div>} {error && <div className="text-destructive p-4 text-center">{error}</div>}
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)} {loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
{!loading && {!loading &&
@@ -204,7 +204,7 @@ export function AsyncSearch<T>({
))} ))}
<CommandGroup> <CommandGroup>
{options.map((option, idx) => ( {options.map((option, idx) => (
<> <React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}>
<CommandItem <CommandItem
key={getOptionValue(option) + `${idx}`} key={getOptionValue(option) + `${idx}`}
value={getOptionValue(option)} value={getOptionValue(option)}
@@ -215,9 +215,9 @@ export function AsyncSearch<T>({
{renderOption(option)} {renderOption(option)}
</CommandItem> </CommandItem>
{idx !== options.length - 1 && ( {idx !== options.length - 1 && (
<div key={idx} className="bg-foreground/10 h-[1px]" /> <div key={`divider-${idx}`} className="bg-foreground/10 h-[1px]" />
)} )}
</> </React.Fragment>
))} ))}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>

View File

@@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { useTabVisibility } from '@/contexts/useTabVisibility';
interface TabContentProps {
tabId: string;
children: React.ReactNode;
className?: string;
}
/**
* TabContent component that manages visibility based on tab selection
* Works with the TabVisibilityContext to show/hide content based on active tab
*/
const TabContent: React.FC<TabContentProps> = ({ tabId, children, className = '' }) => {
const { isTabVisible, setTabVisibility } = useTabVisibility();
const isVisible = isTabVisible(tabId);
// Register this tab with the context when mounted
useEffect(() => {
setTabVisibility(tabId, true);
// Cleanup when unmounted
return () => {
setTabVisibility(tabId, false);
};
}, [tabId, setTabVisibility]);
// Use CSS to hide content instead of not rendering it
// This prevents components from unmounting when tabs are switched
return (
<div className={`${className} ${isVisible ? '' : 'hidden'}`}>
{children}
</div>
);
};
export default TabContent;

View File

@@ -42,9 +42,13 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', 'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'data-[state=inactive]:invisible data-[state=active]:visible',
'h-full w-full',
className className
)} )}
// Force mounting of inactive tabs to preserve WebGL contexts
forceMount
{...props} {...props}
/> />
)) ))

View File

@@ -10,30 +10,43 @@ const TooltipTrigger = TooltipPrimitive.Trigger
const processTooltipContent = (content: string) => { const processTooltipContent = (content: string) => {
if (typeof content !== 'string') return content if (typeof content !== 'string') return content
return content.split('\\n').map((line, i) => ( return (
<React.Fragment key={i}> <div className="relative top-0 pt-1 whitespace-pre-wrap break-words">
{line} {content}
{i < content.split('\\n').length - 1 && <br />} </div>
</React.Fragment> )
))
} }
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>, React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
>(({ className, sideOffset = 4, children, ...props }, ref) => ( side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}
>(({ className, side = 'left', align = 'start', children, ...props }, ref) => {
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0;
}
}, [children]);
return (
<TooltipPrimitive.Content <TooltipPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} side={side}
align={align}
className={cn( className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 mx-1 max-w-sm overflow-hidden rounded-md border px-3 py-2 text-sm shadow-md', 'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md',
className className
)} )}
{...props} {...props}
> >
{typeof children === 'string' ? processTooltipContent(children) : children} {typeof children === 'string' ? processTooltipContent(children) : children}
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
)) );
})
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,53 @@
import React, { useState, useEffect, useMemo } from 'react';
import { TabVisibilityContext } from './context';
import { TabVisibilityContextType } from './types';
import { useSettingsStore } from '@/stores/settings';
interface TabVisibilityProviderProps {
children: React.ReactNode;
}
/**
* Provider component for the TabVisibility context
* Manages the visibility state of tabs throughout the application
*/
export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ children }) => {
// Get current tab from settings store
const currentTab = useSettingsStore.use.currentTab();
// Initialize visibility state with current tab as visible
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
[currentTab]: true
}));
// Update visibility when current tab changes
useEffect(() => {
setVisibleTabs((prev) => ({
...prev,
[currentTab]: true
}));
}, [currentTab]);
// Create the context value with memoization to prevent unnecessary re-renders
const contextValue = useMemo<TabVisibilityContextType>(
() => ({
visibleTabs,
setTabVisibility: (tabId: string, isVisible: boolean) => {
setVisibleTabs((prev) => ({
...prev,
[tabId]: isVisible,
}));
},
isTabVisible: (tabId: string) => !!visibleTabs[tabId],
}),
[visibleTabs]
);
return (
<TabVisibilityContext.Provider value={contextValue}>
{children}
</TabVisibilityContext.Provider>
);
};
export default TabVisibilityProvider;

View File

@@ -0,0 +1,12 @@
import { createContext } from 'react';
import { TabVisibilityContextType } from './types';
// Default context value
const defaultContext: TabVisibilityContextType = {
visibleTabs: {},
setTabVisibility: () => {},
isTabVisible: () => false,
};
// Create the context
export const TabVisibilityContext = createContext<TabVisibilityContextType>(defaultContext);

View File

@@ -0,0 +1,5 @@
export interface TabVisibilityContextType {
visibleTabs: Record<string, boolean>;
setTabVisibility: (tabId: string, isVisible: boolean) => void;
isTabVisible: (tabId: string) => boolean;
}

View File

@@ -0,0 +1,17 @@
import { useContext } from 'react';
import { TabVisibilityContext } from './context';
import { TabVisibilityContextType } from './types';
/**
* Custom hook to access the tab visibility context
* @returns The tab visibility context
*/
export const useTabVisibility = (): TabVisibilityContextType => {
const context = useContext(TabVisibilityContext);
if (!context) {
throw new Error('useTabVisibility must be used within a TabVisibilityProvider');
}
return context;
};

View File

@@ -1,5 +1,40 @@
import { useState, useEffect } from 'react'
import { useTabVisibility } from '@/contexts/useTabVisibility'
import { backendBaseUrl } from '@/lib/constants' import { backendBaseUrl } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
export default function ApiSite() { export default function ApiSite() {
return <iframe src={backendBaseUrl + '/docs'} className="size-full" /> const { t } = useTranslation()
const { isTabVisible } = useTabVisibility()
const isApiTabVisible = isTabVisible('api')
const [iframeLoaded, setIframeLoaded] = useState(false)
// Load the iframe once on component mount
useEffect(() => {
if (!iframeLoaded) {
setIframeLoaded(true)
}
}, [iframeLoaded])
// Use CSS to hide content when tab is not visible
return (
<div className={`size-full ${isApiTabVisible ? '' : 'hidden'}`}>
{iframeLoaded ? (
<iframe
src={backendBaseUrl + '/docs'}
className="size-full w-full h-full"
style={{ width: '100%', height: '100%', border: 'none' }}
// Use key to ensure iframe doesn't reload
key="api-docs-iframe"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="text-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p>{t('apiSite.loading')}</p>
</div>
</div>
)}
</div>
)
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTabVisibility } from '@/contexts/useTabVisibility'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { import {
Table, Table,
@@ -26,6 +27,9 @@ export default function DocumentManager() {
const { t } = useTranslation() 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)
const { isTabVisible } = useTabVisibility()
const isDocumentsTabVisible = isTabVisible('documents')
const initialLoadRef = useRef(false)
const fetchDocuments = useCallback(async () => { const fetchDocuments = useCallback(async () => {
try { try {
@@ -48,11 +52,15 @@ export default function DocumentManager() {
} catch (err) { } catch (err) {
toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) })) toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }))
} }
}, [setDocs]) }, [setDocs, t])
// Only fetch documents when the tab becomes visible for the first time
useEffect(() => { useEffect(() => {
if (isDocumentsTabVisible && !initialLoadRef.current) {
fetchDocuments() fetchDocuments()
}, []) // eslint-disable-line react-hooks/exhaustive-deps initialLoadRef.current = true
}
}, [isDocumentsTabVisible, fetchDocuments])
const scanDocuments = useCallback(async () => { const scanDocuments = useCallback(async () => {
try { try {
@@ -61,21 +69,24 @@ export default function DocumentManager() {
} catch (err) { } catch (err) {
toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) })) toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) }))
} }
}, []) }, [t])
// Only set up polling when the tab is visible and health is good
useEffect(() => { useEffect(() => {
const interval = setInterval(async () => { if (!isDocumentsTabVisible || !health) {
if (!health) {
return return
} }
const interval = setInterval(async () => {
try { try {
await fetchDocuments() await fetchDocuments()
} catch (err) { } catch (err) {
toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) })) toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) }))
} }
}, 5000) }, 5000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [health, fetchDocuments]) }, [health, fetchDocuments, t, isDocumentsTabVisible])
return ( return (
<Card className="!size-full !rounded-none !border-none"> <Card className="!size-full !rounded-none !border-none">

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback, useMemo } from 'react' import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { useTabVisibility } from '@/contexts/useTabVisibility'
// import { MiniMap } from '@react-sigma/minimap' // import { MiniMap } from '@react-sigma/minimap'
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core' import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
import { Settings as SigmaSettings } from 'sigma/settings' import { Settings as SigmaSettings } from 'sigma/settings'
@@ -17,6 +18,7 @@ import Settings from '@/components/graph/Settings'
import GraphSearch from '@/components/graph/GraphSearch' import GraphSearch from '@/components/graph/GraphSearch'
import GraphLabels from '@/components/graph/GraphLabels' import GraphLabels from '@/components/graph/GraphLabels'
import PropertiesView from '@/components/graph/PropertiesView' import PropertiesView from '@/components/graph/PropertiesView'
import SettingsDisplay from '@/components/graph/SettingsDisplay'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
@@ -90,8 +92,12 @@ const GraphEvents = () => {
} }
}, },
// Disable the autoscale at the first down interaction // Disable the autoscale at the first down interaction
mousedown: () => { mousedown: (e) => {
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox()) // Only set custom BBox if it's a drag operation (mouse button is pressed)
const mouseEvent = e.original as MouseEvent;
if (mouseEvent.buttons !== 0 && !sigma.getCustomBBox()) {
sigma.setCustomBBox(sigma.getBBox())
}
} }
}) })
}, [registerEvents, sigma, draggedNode]) }, [registerEvents, sigma, draggedNode])
@@ -101,27 +107,46 @@ const GraphEvents = () => {
const GraphViewer = () => { const GraphViewer = () => {
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings) const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
const sigmaRef = useRef<any>(null)
const initAttemptedRef = useRef(false)
const selectedNode = useGraphStore.use.selectedNode() const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode() const focusedNode = useGraphStore.use.focusedNode()
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode() const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
const isFetching = useGraphStore.use.isFetching()
const shouldRender = useGraphStore.use.shouldRender() // Rendering control state
// Get tab visibility
const { isTabVisible } = useTabVisibility()
const isGraphTabVisible = isTabVisible('knowledge-graph')
const showPropertyPanel = useSettingsStore.use.showPropertyPanel() const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar() const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
const renderLabels = useSettingsStore.use.showNodeLabel()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const enableNodeDrag = useSettingsStore.use.enableNodeDrag() const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
// Handle component mount/unmount and tab visibility
useEffect(() => { useEffect(() => {
setSigmaSettings({ // When component mounts or tab becomes visible
...defaultSigmaSettings, if (isGraphTabVisible && !shouldRender && !isFetching && !initAttemptedRef.current) {
enableEdgeEvents, // If tab is visible but graph is not rendering, try to enable rendering
renderEdgeLabels, useGraphStore.getState().setShouldRender(true)
renderLabels initAttemptedRef.current = true
}) console.log('Graph viewer initialized')
}, [renderLabels, enableEdgeEvents, renderEdgeLabels]) }
// Cleanup function when component unmounts
return () => {
// Only log cleanup, don't actually clean up the WebGL context
// This allows the WebGL context to persist across tab switches
console.log('Graph viewer cleanup')
}
}, [isGraphTabVisible, shouldRender, isFetching])
// Initialize sigma settings once on component mount
// All dynamic settings will be updated in GraphControl using useSetSettings
useEffect(() => {
setSigmaSettings(defaultSigmaSettings)
}, [])
const onSearchFocus = useCallback((value: GraphSearchOption | null) => { const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
if (value === null) useGraphStore.getState().setFocusedNode(null) if (value === null) useGraphStore.getState().setFocusedNode(null)
@@ -142,8 +167,17 @@ const GraphViewer = () => {
[selectedNode] [selectedNode]
) )
// Since TabsContent now forces mounting of all tabs, we need to conditionally render
// the SigmaContainer based on visibility to avoid unnecessary rendering
return ( return (
<SigmaContainer settings={sigmaSettings} className="!bg-background !size-full overflow-hidden"> <div className="relative h-full w-full">
{/* Only render the SigmaContainer when the tab is visible */}
{isGraphTabVisible ? (
<SigmaContainer
settings={sigmaSettings}
className="!bg-background !size-full overflow-hidden"
ref={sigmaRef}
>
<GraphControl /> <GraphControl />
{enableNodeDrag && <GraphEvents />} {enableNodeDrag && <GraphEvents />}
@@ -178,7 +212,28 @@ const GraphViewer = () => {
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2"> {/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
<MiniMap width="100px" height="100px" /> <MiniMap width="100px" height="100px" />
</div> */} </div> */}
<SettingsDisplay />
</SigmaContainer> </SigmaContainer>
) : (
// Placeholder when tab is not visible
<div className="flex h-full w-full items-center justify-center">
<div className="text-center text-muted-foreground">
{/* Placeholder content */}
</div>
</div>
)}
{/* Loading overlay - shown when data is loading */}
{isFetching && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p>Loading Graph Data...</p>
</div>
</div>
)}
</div>
) )
} }

View File

@@ -1,6 +1,6 @@
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { SiteInfo } from '@/lib/constants' import { SiteInfo } from '@/lib/constants'
import ThemeToggle from '@/components/ThemeToggle' import AppSettings from '@/components/AppSettings'
import { TabsList, TabsTrigger } from '@/components/ui/Tabs' import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -67,12 +67,14 @@ export default function SiteHeader() {
</div> </div>
<nav className="flex items-center"> <nav className="flex items-center">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}> <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>
</Button> </Button>
<ThemeToggle /> <AppSettings />
</div>
</nav> </nav>
</header> </header>
) )

View File

@@ -1,11 +1,12 @@
import Graph, { DirectedGraph } from 'graphology' import Graph, { DirectedGraph } from 'graphology'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { randomColor, errorMessage } from '@/lib/utils' import { randomColor, errorMessage } from '@/lib/utils'
import * as Constants from '@/lib/constants' import * as Constants from '@/lib/constants'
import { useGraphStore, RawGraph } from '@/stores/graph' import { useGraphStore, RawGraph } from '@/stores/graph'
import { queryGraphs } from '@/api/lightrag' import { queryGraphs } from '@/api/lightrag'
import { useBackendState } from '@/stores/state' import { useBackendState } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useTabVisibility } from '@/contexts/useTabVisibility'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
@@ -136,15 +137,23 @@ const fetchGraph = async (label: string, maxDepth: number, minDegree: number) =>
return rawGraph return rawGraph
} }
// Create a new graph instance with the raw graph data
const createSigmaGraph = (rawGraph: RawGraph | null) => { const createSigmaGraph = (rawGraph: RawGraph | null) => {
// Always create a new graph instance
const graph = new DirectedGraph() const graph = new DirectedGraph()
// Add nodes from raw graph data
for (const rawNode of rawGraph?.nodes ?? []) { for (const rawNode of rawGraph?.nodes ?? []) {
// Ensure we have fresh random positions for nodes
seedrandom(rawNode.id + Date.now().toString(), { global: true })
const x = Math.random()
const y = Math.random()
graph.addNode(rawNode.id, { graph.addNode(rawNode.id, {
label: rawNode.labels.join(', '), label: rawNode.labels.join(', '),
color: rawNode.color, color: rawNode.color,
x: rawNode.x, x: x,
y: rawNode.y, y: y,
size: rawNode.size, size: rawNode.size,
// for node-border // for node-border
borderColor: Constants.nodeBorderColor, borderColor: Constants.nodeBorderColor,
@@ -152,6 +161,7 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
}) })
} }
// Add edges from raw graph data
for (const rawEdge of rawGraph?.edges ?? []) { for (const rawEdge of rawGraph?.edges ?? []) {
rawEdge.dynamicId = graph.addDirectedEdge(rawEdge.source, rawEdge.target, { rawEdge.dynamicId = graph.addDirectedEdge(rawEdge.source, rawEdge.target, {
label: rawEdge.type || undefined label: rawEdge.type || undefined
@@ -161,14 +171,30 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
return graph return graph
} }
const lastQueryLabel = { label: '', maxQueryDepth: 0, minDegree: 0 }
const useLightrangeGraph = () => { const useLightrangeGraph = () => {
const queryLabel = useSettingsStore.use.queryLabel() const queryLabel = useSettingsStore.use.queryLabel()
const rawGraph = useGraphStore.use.rawGraph() const rawGraph = useGraphStore.use.rawGraph()
const sigmaGraph = useGraphStore.use.sigmaGraph() const sigmaGraph = useGraphStore.use.sigmaGraph()
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth() const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
const minDegree = useSettingsStore.use.graphMinDegree() const minDegree = useSettingsStore.use.graphMinDegree()
const isFetching = useGraphStore.use.isFetching()
// Get tab visibility
const { isTabVisible } = useTabVisibility()
const isGraphTabVisible = isTabVisible('knowledge-graph')
// Track previous parameters to detect actual changes
const prevParamsRef = useRef({ queryLabel, maxQueryDepth, minDegree })
// Use ref to track if data has been loaded and initial load
const dataLoadedRef = useRef(false)
const initialLoadRef = useRef(false)
// Check if parameters have changed
const paramsChanged =
prevParamsRef.current.queryLabel !== queryLabel ||
prevParamsRef.current.maxQueryDepth !== maxQueryDepth ||
prevParamsRef.current.minDegree !== minDegree
const getNode = useCallback( const getNode = useCallback(
(nodeId: string) => { (nodeId: string) => {
@@ -184,35 +210,131 @@ const useLightrangeGraph = () => {
[rawGraph] [rawGraph]
) )
useEffect(() => { // Track if a fetch is in progress to prevent multiple simultaneous fetches
if (queryLabel) { const fetchInProgressRef = useRef(false)
if (lastQueryLabel.label !== queryLabel ||
lastQueryLabel.maxQueryDepth !== maxQueryDepth ||
lastQueryLabel.minDegree !== minDegree) {
lastQueryLabel.label = queryLabel
lastQueryLabel.maxQueryDepth = maxQueryDepth
lastQueryLabel.minDegree = minDegree
// Data fetching logic - simplified but preserving TAB visibility check
useEffect(() => {
// Skip if fetch is already in progress
if (fetchInProgressRef.current) {
return
}
// If there's no query label, reset the graph
if (!queryLabel) {
if (rawGraph !== null || sigmaGraph !== null) {
const state = useGraphStore.getState() const state = useGraphStore.getState()
state.reset() state.reset()
fetchGraph(queryLabel, maxQueryDepth, minDegree).then((data) => { state.setGraphDataFetchAttempted(false)
// console.debug('Query label: ' + queryLabel) state.setLabelsFetchAttempted(false)
state.setSigmaGraph(createSigmaGraph(data)) }
data?.buildDynamicMap() dataLoadedRef.current = false
state.setRawGraph(data) initialLoadRef.current = false
return
}
// Check if parameters have changed
if (!isFetching && !fetchInProgressRef.current &&
(paramsChanged || !useGraphStore.getState().graphDataFetchAttempted)) {
// Only fetch data if the Graph tab is visible
if (!isGraphTabVisible) {
console.log('Graph tab not visible, skipping data fetch');
return;
}
// Set flags
fetchInProgressRef.current = true
useGraphStore.getState().setGraphDataFetchAttempted(true)
const state = useGraphStore.getState()
state.setIsFetching(true)
state.setShouldRender(false) // Disable rendering during data loading
// Clear selection and highlighted nodes before fetching new graph
state.clearSelection()
if (state.sigmaGraph) {
state.sigmaGraph.forEachNode((node) => {
state.sigmaGraph?.setNodeAttribute(node, 'highlighted', false)
}) })
} }
} else {
// Update parameter reference
prevParamsRef.current = { queryLabel, maxQueryDepth, minDegree }
console.log('Fetching graph data...')
// Use a local copy of the parameters
const currentQueryLabel = queryLabel
const currentMaxQueryDepth = maxQueryDepth
const currentMinDegree = minDegree
// Fetch graph data
fetchGraph(currentQueryLabel, currentMaxQueryDepth, currentMinDegree).then((data) => {
const state = useGraphStore.getState() const state = useGraphStore.getState()
// Reset state
state.reset() state.reset()
state.setSigmaGraph(new DirectedGraph())
// Create and set new graph directly
const newSigmaGraph = createSigmaGraph(data)
data?.buildDynamicMap()
// Set new graph data
state.setSigmaGraph(newSigmaGraph)
state.setRawGraph(data)
// No longer need to extract labels from graph data
// Update flags
dataLoadedRef.current = true
initialLoadRef.current = true
fetchInProgressRef.current = false
// Reset camera view
state.setMoveToSelectedNode(true)
// Enable rendering if the tab is visible
state.setShouldRender(isGraphTabVisible)
state.setIsFetching(false)
}).catch((error) => {
console.error('Error fetching graph data:', error)
// Reset state on error
const state = useGraphStore.getState()
state.setIsFetching(false)
state.setShouldRender(isGraphTabVisible)
dataLoadedRef.current = false
fetchInProgressRef.current = false
state.setGraphDataFetchAttempted(false)
})
} }
}, [queryLabel, maxQueryDepth, minDegree]) }, [queryLabel, maxQueryDepth, minDegree, isFetching, paramsChanged, isGraphTabVisible, rawGraph, sigmaGraph])
// Update rendering state and handle tab visibility changes
useEffect(() => {
// When tab becomes visible
if (isGraphTabVisible) {
// If we have data, enable rendering
if (rawGraph) {
useGraphStore.getState().setShouldRender(true)
}
// We no longer reset the fetch attempted flag here to prevent continuous API calls
} else {
// When tab becomes invisible, disable rendering
useGraphStore.getState().setShouldRender(false)
}
}, [isGraphTabVisible, rawGraph])
const lightrageGraph = useCallback(() => { const lightrageGraph = useCallback(() => {
// If we already have a graph instance, return it
if (sigmaGraph) { if (sigmaGraph) {
return sigmaGraph as Graph<NodeType, EdgeType> return sigmaGraph as Graph<NodeType, EdgeType>
} }
// If no graph exists yet, create a new one and store it
console.log('Creating new Sigma graph instance')
const graph = new DirectedGraph() const graph = new DirectedGraph()
useGraphStore.getState().setSigmaGraph(graph) useGraphStore.getState().setSigmaGraph(graph)
return graph as Graph<NodeType, EdgeType> return graph as Graph<NodeType, EdgeType>

View File

@@ -1,21 +0,0 @@
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,37 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import en from './locales/en.json'
import zh from './locales/zh.json'
// Function to sync i18n with store state
export const initializeI18n = async (): Promise<typeof i18n> => {
// Get initial language from store
const initialLanguage = useSettingsStore.getState().language
// Initialize with store language
await i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
lng: initialLanguage,
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
})
// Subscribe to language changes
useSettingsStore.subscribe((state) => {
const currentLanguage = state.language
if (i18n.language !== currentLanguage) {
i18n.changeLanguage(currentLanguage)
}
})
return i18n
}
export default i18n

View File

@@ -15,8 +15,8 @@ export const edgeColorDarkTheme = '#969696'
export const edgeColorSelected = '#F57F17' export const edgeColorSelected = '#F57F17'
export const edgeColorHighlighted = '#B2EBF2' export const edgeColorHighlighted = '#B2EBF2'
export const searchResultLimit = 20 export const searchResultLimit = 50
export const labelListLimit = 40 export const labelListLimit = 100
export const minNodeSize = 4 export const minNodeSize = 4
export const maxNodeSize = 20 export const maxNodeSize = 20

View File

@@ -1,4 +1,11 @@
{ {
"settings": {
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"header": { "header": {
"documents": "Documents", "documents": "Documents",
"knowledgeGraph": "Knowledge Graph", "knowledgeGraph": "Knowledge Graph",
@@ -79,9 +86,12 @@
"maxQueryDepth": "Max Query Depth", "maxQueryDepth": "Max Query Depth",
"minDegree": "Minimum Degree", "minDegree": "Minimum Degree",
"maxLayoutIterations": "Max Layout Iterations", "maxLayoutIterations": "Max Layout Iterations",
"depth": "Depth",
"degree": "Degree",
"apiKey": "API Key", "apiKey": "API Key",
"enterYourAPIkey": "Enter your API key", "enterYourAPIkey": "Enter your API key",
"save": "Save" "save": "Save",
"refreshLayout": "Refresh Layout"
}, },
"zoomControl": { "zoomControl": {
@@ -140,7 +150,14 @@
"labels": "Labels", "labels": "Labels",
"degree": "Degree", "degree": "Degree",
"properties": "Properties", "properties": "Properties",
"relationships": "Relationships" "relationships": "Relationships",
"propertyNames": {
"description": "Description",
"entity_id": "Name",
"entity_type": "Type",
"source_id": "SrcID",
"Neighbour": "Neigh"
}
}, },
"edge": { "edge": {
"title": "Relationship", "title": "Relationship",
@@ -230,5 +247,8 @@
"streamResponse": "Stream Response", "streamResponse": "Stream Response",
"streamResponseTooltip": "If True, enables streaming output for real-time responses" "streamResponseTooltip": "If True, enables streaming output for real-time responses"
} }
},
"apiSite": {
"loading": "Loading API Documentation..."
} }
} }

View File

@@ -1,4 +1,11 @@
{ {
"settings": {
"language": "语言",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统"
},
"header": { "header": {
"documents": "文档", "documents": "文档",
"knowledgeGraph": "知识图谱", "knowledgeGraph": "知识图谱",
@@ -6,41 +13,41 @@
"api": "API", "api": "API",
"projectRepository": "项目仓库", "projectRepository": "项目仓库",
"themeToggle": { "themeToggle": {
"switchToLight": "切换到色主题", "switchToLight": "切换到色主题",
"switchToDark": "切换到色主题" "switchToDark": "切换到色主题"
} }
}, },
"documentPanel": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
"button": "清", "button": "清",
"tooltip": "清文档", "tooltip": "清文档",
"title": "清文档", "title": "清文档",
"confirm": "确定要清所有文档吗?", "confirm": "确定要清所有文档吗?",
"confirmButton": "确定", "confirmButton": "确定",
"success": "文档已成功清除", "success": "文档清空成功",
"failed": "清文档失败:\n{{message}}", "failed": "清文档失败\n{{message}}",
"error": "清文档失败:\n{{error}}" "error": "清文档失败\n{{error}}"
}, },
"uploadDocuments": { "uploadDocuments": {
"button": "上传", "button": "上传",
"tooltip": "上传文档", "tooltip": "上传文档",
"title": "上传文档", "title": "上传文档",
"description": "拖放文档到此处或点击浏览", "description": "拖拽文件到此处或点击浏览",
"uploading": "正在上传 {{name}}: {{percent}}%", "uploading": "正在上传 {{name}}{{percent}}%",
"success": "上传成功:\n{{name}} 上传成", "success": "上传成功\n{{name}} 上传成",
"failed": "上传失败:\n{{name}}\n{{message}}", "failed": "上传失败\n{{name}}\n{{message}}",
"error": "上传失败:\n{{name}}\n{{error}}", "error": "上传失败\n{{name}}\n{{error}}",
"generalError": "上传失败\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" "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": { "documentManager": {
"title": "文档管理", "title": "文档管理",
"scanButton": "扫描", "scanButton": "扫描",
"scanTooltip": "扫描文档", "scanTooltip": "扫描文档",
"uploadedTitle": "已上传文档", "uploadedTitle": "已上传文档",
"uploadedDescription": "已上传文档及其状态列表。", "uploadedDescription": "已上传文档列表及其状态",
"emptyTitle": "无文档", "emptyTitle": "无文档",
"emptyDescription": "尚未上传任何文档", "emptyDescription": "还没有上传任何文档",
"columns": { "columns": {
"id": "ID", "id": "ID",
"summary": "摘要", "summary": "摘要",
@@ -54,7 +61,7 @@
"status": { "status": {
"completed": "已完成", "completed": "已完成",
"processing": "处理中", "processing": "处理中",
"pending": "待处理", "pending": "等待中",
"failed": "失败" "failed": "失败"
}, },
"errors": { "errors": {
@@ -74,39 +81,39 @@
"showNodeLabel": "显示节点标签", "showNodeLabel": "显示节点标签",
"nodeDraggable": "节点可拖动", "nodeDraggable": "节点可拖动",
"showEdgeLabel": "显示边标签", "showEdgeLabel": "显示边标签",
"hideUnselectedEdges": "隐藏未选中边", "hideUnselectedEdges": "隐藏未选中边",
"edgeEvents": "边事件", "edgeEvents": "边事件",
"maxQueryDepth": "最大查询深度", "maxQueryDepth": "最大查询深度",
"minDegree": "最小度数", "minDegree": "最小度数",
"maxLayoutIterations": "最大布局迭代次数", "maxLayoutIterations": "最大布局迭代次数",
"depth": "深度",
"degree": "邻边",
"apiKey": "API密钥", "apiKey": "API密钥",
"enterYourAPIkey": "输入您的API密钥", "enterYourAPIkey": "输入您的API密钥",
"save": "保存" "save": "保存",
"refreshLayout": "刷新布局"
}, },
"zoomControl": { "zoomControl": {
"zoomIn": "放大", "zoomIn": "放大",
"zoomOut": "缩小", "zoomOut": "缩小",
"resetZoom": "重置缩放" "resetZoom": "重置缩放"
}, },
"layoutsControl": { "layoutsControl": {
"startAnimation": "开始布局动画", "startAnimation": "开始布局动画",
"stopAnimation": "停止布局动画", "stopAnimation": "停止布局动画",
"layoutGraph": "布局", "layoutGraph": "布局",
"layouts": { "layouts": {
"Circular": "环形布局", "Circular": "环形",
"Circlepack": "圆形打包布局", "Circlepack": "圆形打包",
"Random": "随机布局", "Random": "随机",
"Noverlaps": "无重叠布局", "Noverlaps": "无重叠",
"Force Directed": "力导向布局", "Force Directed": "力导向",
"Force Atlas": "力导向图谱布局" "Force Atlas": "力"
} }
}, },
"fullScreenControl": { "fullScreenControl": {
"fullScreen": "全屏", "fullScreen": "全屏",
"windowed": "窗口模式" "windowed": "窗口"
} }
}, },
"statusIndicator": { "statusIndicator": {
@@ -122,7 +129,7 @@
"llmBinding": "LLM绑定", "llmBinding": "LLM绑定",
"llmBindingHost": "LLM绑定主机", "llmBindingHost": "LLM绑定主机",
"llmModel": "LLM模型", "llmModel": "LLM模型",
"maxTokens": "最大 Token 数", "maxTokens": "最大令牌数",
"embeddingConfig": "嵌入配置", "embeddingConfig": "嵌入配置",
"embeddingBinding": "嵌入绑定", "embeddingBinding": "嵌入绑定",
"embeddingBindingHost": "嵌入绑定主机", "embeddingBindingHost": "嵌入绑定主机",
@@ -140,96 +147,93 @@
"labels": "标签", "labels": "标签",
"degree": "度数", "degree": "度数",
"properties": "属性", "properties": "属性",
"relationships": "关系" "relationships": "关系",
"propertyNames": {
"description": "描述",
"entity_id": "名称",
"entity_type": "类型",
"source_id": "信源ID",
"Neighbour": "邻接"
}
}, },
"edge": { "edge": {
"title": "关系", "title": "关系",
"id": "ID", "id": "ID",
"type": "类型", "type": "类型",
"source": "源", "source": "源节点",
"target": "目标", "target": "目标节点",
"properties": "属性" "properties": "属性"
} }
}, },
"search": { "search": {
"placeholder": "搜索节点...", "placeholder": "搜索节点...",
"message": "以及其它 {count} " "message": "还有 {count} "
}, },
"graphLabels": { "graphLabels": {
"selectTooltip": "选择查询标签", "selectTooltip": "选择查询标签",
"noLabels": "未找到标签", "noLabels": "未找到标签",
"label": "标签", "label": "标签",
"placeholder": "搜索标签...", "placeholder": "搜索标签...",
"andOthers": "以及其它 {count} 个" "andOthers": "还有 {count} 个"
} }
}, },
"retrievePanel": { "retrievePanel": {
"chatMessage": { "chatMessage": {
"copyTooltip": "复制到剪贴板", "copyTooltip": "复制到剪贴板",
"copyError": "无法复制文本到剪贴板" "copyError": "复制文本到剪贴板失败"
}, },
"retrieval": { "retrieval": {
"startPrompt": "在下面输入您的查询开始检索", "startPrompt": "输入查询开始检索",
"clear": "清", "clear": "清",
"send": "发送", "send": "发送",
"placeholder": "输入您的查询...", "placeholder": "输入查询...",
"error": "错误:无法获取响应" "error": "错误:获取响应失败"
}, },
"querySettings": { "querySettings": {
"parametersTitle": "参数设置", "parametersTitle": "参数",
"parametersDescription": "配置查询参数", "parametersDescription": "配置查询参数",
"queryMode": "查询模式", "queryMode": "查询模式",
"queryModeTooltip": "选择检索策略:\n• 朴素:不使用高级技术的基本搜索\n• 本地:基于上下文信息检索\n• 全局:利用全局知识库\n• 混合:结合本地和全局检索\n• 综合:集成知识图谱向量检索", "queryModeTooltip": "选择检索策略:\n• Naive基础搜索无高级技术\n• Local上下文相关信息检索\n• Global:利用全局知识库\n• Hybrid:结合本地和全局检索\n• Mix整合知识图谱向量检索",
"queryModeOptions": { "queryModeOptions": {
"naive": "朴素", "naive": "朴素",
"local": "本地", "local": "本地",
"global": "全局", "global": "全局",
"hybrid": "混合", "hybrid": "混合",
"mix": "合" "mix": "合"
}, },
"responseFormat": "响应格式", "responseFormat": "响应格式",
"responseFormatTooltip": "定义响应格式。例如:\n• 多段落\n• 单段落\n• 项目符号", "responseFormatTooltip": "定义响应格式。例如:\n• 多段落\n• 单段落\n• 要点",
"responseFormatOptions": { "responseFormatOptions": {
"multipleParagraphs": "多段落", "multipleParagraphs": "多段落",
"singleParagraph": "单段落", "singleParagraph": "单段落",
"bulletPoints": "项目符号" "bulletPoints": "要点"
}, },
"topK": "Top K结果",
"topK": "Top K 结果数", "topKTooltip": "检索的顶部项目数。在'local'模式下表示实体,在'global'模式下表示关系",
"topKTooltip": "要检索的前 K 个项目数量。在“本地”模式下表示实体,在“全局”模式下表示关系", "topKPlaceholder": "结果数量",
"topKPlaceholder": "结果数", "maxTokensTextUnit": "文本单元最大令牌数",
"maxTokensTextUnitTooltip": "每个检索文本块允许的最大令牌数",
"maxTokensTextUnit": "文本单元最大 Token 数", "maxTokensGlobalContext": "全局上下文最大令牌数",
"maxTokensTextUnitTooltip": "每个检索到的文本块允许的最大 Token 数", "maxTokensGlobalContextTooltip": "全局检索中关系描述的最大令牌数",
"maxTokensLocalContext": "本地上下文最大令牌数",
"maxTokensGlobalContext": "全局上下文最大 Token 数", "maxTokensLocalContextTooltip": "本地检索中实体描述的最大令牌数",
"maxTokensGlobalContextTooltip": "在全局检索中为关系描述分配的最大 Token 数",
"maxTokensLocalContext": "本地上下文最大 Token 数",
"maxTokensLocalContextTooltip": "在本地检索中为实体描述分配的最大 Token 数",
"historyTurns": "历史轮次", "historyTurns": "历史轮次",
"historyTurnsTooltip": "响应上下文中考虑的完整对话轮次(用户-助手对)", "historyTurnsTooltip": "响应上下文中考虑的完整对话轮次(用户-助手对)数量",
"historyTurnsPlaceholder": "历史轮次的数量", "historyTurnsPlaceholder": "历史轮次",
"hlKeywords": "高级关键词", "hlKeywords": "高级关键词",
"hlKeywordsTooltip": "检索优先考虑的高级关键词。用逗号分隔", "hlKeywordsTooltip": "检索优先考虑的高级关键词列表。用逗号分隔",
"hlkeywordsPlaceHolder": "输入关键词", "hlkeywordsPlaceHolder": "输入关键词",
"llKeywords": "低级关键词", "llKeywords": "低级关键词",
"llKeywordsTooltip": "用于化检索点的低级关键词。用逗号分隔", "llKeywordsTooltip": "用于化检索点的低级关键词列表。用逗号分隔",
"onlyNeedContext": "仅需上下文",
"onlyNeedContext": "仅需要上下文", "onlyNeedContextTooltip": "如果为True仅返回检索到的上下文而不生成响应",
"onlyNeedContextTooltip": "如果为 True则仅返回检索到的上下文而不会生成回复", "onlyNeedPrompt": "仅需提示",
"onlyNeedPromptTooltip": "如果为True仅返回生成的提示而不产生响应",
"onlyNeedPrompt": "仅需要提示",
"onlyNeedPromptTooltip": "如果为 True则仅返回生成的提示而不会生成回复",
"streamResponse": "流式响应", "streamResponse": "流式响应",
"streamResponseTooltip": "如果为 True启用流式输出以获得实时响应" "streamResponseTooltip": "如果为True启用实时流式输出响应"
} }
},
"apiSite": {
"loading": "正在加载 API 文档..."
} }
} }

View File

@@ -1,12 +1,5 @@
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 { Root } from '@/components/Root'
import "./i18n";
createRoot(document.getElementById('root')!).render(<Root />)
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import { createSelectors } from '@/lib/utils' import { createSelectors } from '@/lib/utils'
import { DirectedGraph } from 'graphology' import { DirectedGraph } from 'graphology'
import { getGraphLabels } from '@/api/lightrag'
export type RawNodeType = { export type RawNodeType = {
id: string id: string
@@ -65,9 +66,17 @@ interface GraphState {
rawGraph: RawGraph | null rawGraph: RawGraph | null
sigmaGraph: DirectedGraph | null sigmaGraph: DirectedGraph | null
allDatabaseLabels: string[]
moveToSelectedNode: boolean moveToSelectedNode: boolean
isFetching: boolean
shouldRender: boolean
// Global flags to track data fetching attempts
graphDataFetchAttempted: boolean
labelsFetchAttempted: boolean
refreshLayout: () => void
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
setFocusedNode: (nodeId: string | null) => void setFocusedNode: (nodeId: string | null) => void
setSelectedEdge: (edgeId: string | null) => void setSelectedEdge: (edgeId: string | null) => void
@@ -79,19 +88,47 @@ interface GraphState {
setRawGraph: (rawGraph: RawGraph | null) => void setRawGraph: (rawGraph: RawGraph | null) => void
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
setAllDatabaseLabels: (labels: string[]) => void
fetchAllDatabaseLabels: () => Promise<void>
setIsFetching: (isFetching: boolean) => void
setShouldRender: (shouldRender: boolean) => void
// Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => void
setLabelsFetchAttempted: (attempted: boolean) => void
} }
const useGraphStoreBase = create<GraphState>()((set) => ({ const useGraphStoreBase = create<GraphState>()((set, get) => ({
selectedNode: null, selectedNode: null,
focusedNode: null, focusedNode: null,
selectedEdge: null, selectedEdge: null,
focusedEdge: null, focusedEdge: null,
moveToSelectedNode: false, moveToSelectedNode: false,
isFetching: false,
shouldRender: false,
// Initialize global flags
graphDataFetchAttempted: false,
labelsFetchAttempted: false,
rawGraph: null, rawGraph: null,
sigmaGraph: null, sigmaGraph: null,
allDatabaseLabels: ['*'],
refreshLayout: () => {
const currentGraph = get().sigmaGraph;
if (currentGraph) {
get().clearSelection();
get().setSigmaGraph(null);
setTimeout(() => {
get().setSigmaGraph(currentGraph);
}, 10);
}
},
setIsFetching: (isFetching: boolean) => set({ isFetching }),
setShouldRender: (shouldRender: boolean) => set({ shouldRender }),
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) =>
set({ selectedNode: nodeId, moveToSelectedNode }), set({ selectedNode: nodeId, moveToSelectedNode }),
setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }), setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }),
@@ -104,25 +141,58 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
selectedEdge: null, selectedEdge: null,
focusedEdge: null focusedEdge: null
}), }),
reset: () => reset: () => {
// Get the existing graph
const existingGraph = get().sigmaGraph;
// If we have an existing graph, clear it by removing all nodes
if (existingGraph) {
const nodes = Array.from(existingGraph.nodes());
nodes.forEach(node => existingGraph.dropNode(node));
}
set({ set({
selectedNode: null, selectedNode: null,
focusedNode: null, focusedNode: null,
selectedEdge: null, selectedEdge: null,
focusedEdge: null, focusedEdge: null,
rawGraph: null, rawGraph: null,
sigmaGraph: null, // Keep the existing graph instance but with cleared data
moveToSelectedNode: false moveToSelectedNode: false,
}), shouldRender: false
});
},
setRawGraph: (rawGraph: RawGraph | null) => setRawGraph: (rawGraph: RawGraph | null) =>
set({ set({
rawGraph rawGraph
}), }),
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => set({ sigmaGraph }), setSigmaGraph: (sigmaGraph: DirectedGraph | null) => {
// Replace graph instance, no need to keep WebGL context
set({ sigmaGraph });
},
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }) setAllDatabaseLabels: (labels: string[]) => set({ allDatabaseLabels: labels }),
fetchAllDatabaseLabels: async () => {
try {
console.log('Fetching all database labels...');
const labels = await getGraphLabels();
set({ allDatabaseLabels: ['*', ...labels] });
return;
} catch (error) {
console.error('Failed to fetch all database labels:', error);
set({ allDatabaseLabels: ['*'] });
throw error;
}
},
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),
// Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),
setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted })
})) }))
const useGraphStore = createSelectors(useGraphStoreBase) const useGraphStore = createSelectors(useGraphStoreBase)

View File

@@ -5,6 +5,7 @@ import { defaultQueryLabel } from '@/lib/constants'
import { Message, QueryRequest } from '@/api/lightrag' import { Message, QueryRequest } from '@/api/lightrag'
type Theme = 'dark' | 'light' | 'system' type Theme = 'dark' | 'light' | 'system'
type Language = 'en' | 'zh'
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'
interface SettingsState { interface SettingsState {
@@ -46,6 +47,9 @@ interface SettingsState {
theme: Theme theme: Theme
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
language: Language
setLanguage: (lang: Language) => void
enableHealthCheck: boolean enableHealthCheck: boolean
setEnableHealthCheck: (enable: boolean) => void setEnableHealthCheck: (enable: boolean) => void
@@ -57,7 +61,7 @@ const useSettingsStoreBase = create<SettingsState>()(
persist( persist(
(set) => ({ (set) => ({
theme: 'system', theme: 'system',
language: 'en',
showPropertyPanel: true, showPropertyPanel: true,
showNodeSearchBar: true, showNodeSearchBar: true,
@@ -70,7 +74,7 @@ const useSettingsStoreBase = create<SettingsState>()(
graphQueryMaxDepth: 3, graphQueryMaxDepth: 3,
graphMinDegree: 0, graphMinDegree: 0,
graphLayoutMaxIterations: 10, graphLayoutMaxIterations: 15,
queryLabel: defaultQueryLabel, queryLabel: defaultQueryLabel,
@@ -99,6 +103,16 @@ const useSettingsStoreBase = create<SettingsState>()(
setTheme: (theme: Theme) => set({ theme }), setTheme: (theme: Theme) => set({ theme }),
setLanguage: (language: Language) => {
set({ language })
// Update i18n after state is updated
import('i18next').then(({ default: i18n }) => {
if (i18n.language !== language) {
i18n.changeLanguage(language)
}
})
},
setGraphLayoutMaxIterations: (iterations: number) => setGraphLayoutMaxIterations: (iterations: number) =>
set({ set({
graphLayoutMaxIterations: iterations graphLayoutMaxIterations: iterations
@@ -129,7 +143,7 @@ const useSettingsStoreBase = create<SettingsState>()(
{ {
name: 'settings-storage', name: 'settings-storage',
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
version: 7, version: 8,
migrate: (state: any, version: number) => { migrate: (state: any, version: number) => {
if (version < 2) { if (version < 2) {
state.showEdgeLabel = false state.showEdgeLabel = false
@@ -166,7 +180,11 @@ const useSettingsStoreBase = create<SettingsState>()(
} }
if (version < 7) { if (version < 7) {
state.graphQueryMaxDepth = 3 state.graphQueryMaxDepth = 3
state.graphLayoutMaxIterations = 10 state.graphLayoutMaxIterations = 15
}
if (version < 8) {
state.graphMinDegree = 0
state.language = 'en'
} }
return state return state
} }