cherry-pick 5155edd8
This commit is contained in:
parent
75d392f377
commit
2a451c4e22
11 changed files with 718 additions and 107 deletions
|
|
@ -17,6 +17,7 @@ class EntityUpdateRequest(BaseModel):
|
||||||
entity_name: str
|
entity_name: str
|
||||||
updated_data: Dict[str, Any]
|
updated_data: Dict[str, Any]
|
||||||
allow_rename: bool = False
|
allow_rename: bool = False
|
||||||
|
allow_merge: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RelationUpdateRequest(BaseModel):
|
class RelationUpdateRequest(BaseModel):
|
||||||
|
|
@ -221,22 +222,178 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Update an entity's properties in the knowledge graph
|
Update an entity's properties in the knowledge graph
|
||||||
|
|
||||||
|
This endpoint allows updating entity properties, including renaming entities.
|
||||||
|
When renaming to an existing entity name, the behavior depends on allow_merge:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request (EntityUpdateRequest): Request containing entity name, updated data, and rename flag
|
request (EntityUpdateRequest): Request containing:
|
||||||
|
- entity_name (str): Name of the entity to update
|
||||||
|
- updated_data (Dict[str, Any]): Dictionary of properties to update
|
||||||
|
- allow_rename (bool): Whether to allow entity renaming (default: False)
|
||||||
|
- allow_merge (bool): Whether to merge into existing entity when renaming
|
||||||
|
causes name conflict (default: False)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: Updated entity information
|
Dict with the following structure:
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Entity updated successfully" | "Entity merged successfully into 'target_name'",
|
||||||
|
"data": {
|
||||||
|
"entity_name": str, # Final entity name
|
||||||
|
"description": str, # Entity description
|
||||||
|
"entity_type": str, # Entity type
|
||||||
|
"source_id": str, # Source chunk IDs
|
||||||
|
... # Other entity properties
|
||||||
|
},
|
||||||
|
"operation_summary": {
|
||||||
|
"merged": bool, # Whether entity was merged into another
|
||||||
|
"merge_status": str, # "success" | "failed" | "not_attempted"
|
||||||
|
"merge_error": str | None, # Error message if merge failed
|
||||||
|
"operation_status": str, # "success" | "partial_success" | "failure"
|
||||||
|
"target_entity": str | None, # Target entity name if renaming/merging
|
||||||
|
"final_entity": str, # Final entity name after operation
|
||||||
|
"renamed": bool # Whether entity was renamed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
operation_status values explained:
|
||||||
|
- "success": All operations completed successfully
|
||||||
|
* For simple updates: entity properties updated
|
||||||
|
* For renames: entity renamed successfully
|
||||||
|
* For merges: non-name updates applied AND merge completed
|
||||||
|
|
||||||
|
- "partial_success": Update succeeded but merge failed
|
||||||
|
* Non-name property updates were applied successfully
|
||||||
|
* Merge operation failed (entity not merged)
|
||||||
|
* Original entity still exists with updated properties
|
||||||
|
* Use merge_error for failure details
|
||||||
|
|
||||||
|
- "failure": Operation failed completely
|
||||||
|
* If merge_status == "failed": Merge attempted but both update and merge failed
|
||||||
|
* If merge_status == "not_attempted": Regular update failed
|
||||||
|
* No changes were applied to the entity
|
||||||
|
|
||||||
|
merge_status values explained:
|
||||||
|
- "success": Entity successfully merged into target entity
|
||||||
|
- "failed": Merge operation was attempted but failed
|
||||||
|
- "not_attempted": No merge was attempted (normal update/rename)
|
||||||
|
|
||||||
|
Behavior when renaming to an existing entity:
|
||||||
|
- If allow_merge=False: Raises ValueError with 400 status (default behavior)
|
||||||
|
- If allow_merge=True: Automatically merges the source entity into the existing target entity,
|
||||||
|
preserving all relationships and applying non-name updates first
|
||||||
|
|
||||||
|
Example Request (simple update):
|
||||||
|
POST /graph/entity/edit
|
||||||
|
{
|
||||||
|
"entity_name": "Tesla",
|
||||||
|
"updated_data": {"description": "Updated description"},
|
||||||
|
"allow_rename": false,
|
||||||
|
"allow_merge": false
|
||||||
|
}
|
||||||
|
|
||||||
|
Example Response (simple update success):
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Entity updated successfully",
|
||||||
|
"data": { ... },
|
||||||
|
"operation_summary": {
|
||||||
|
"merged": false,
|
||||||
|
"merge_status": "not_attempted",
|
||||||
|
"merge_error": null,
|
||||||
|
"operation_status": "success",
|
||||||
|
"target_entity": null,
|
||||||
|
"final_entity": "Tesla",
|
||||||
|
"renamed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Example Request (rename with auto-merge):
|
||||||
|
POST /graph/entity/edit
|
||||||
|
{
|
||||||
|
"entity_name": "Elon Msk",
|
||||||
|
"updated_data": {
|
||||||
|
"entity_name": "Elon Musk",
|
||||||
|
"description": "Corrected description"
|
||||||
|
},
|
||||||
|
"allow_rename": true,
|
||||||
|
"allow_merge": true
|
||||||
|
}
|
||||||
|
|
||||||
|
Example Response (merge success):
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Entity merged successfully into 'Elon Musk'",
|
||||||
|
"data": { ... },
|
||||||
|
"operation_summary": {
|
||||||
|
"merged": true,
|
||||||
|
"merge_status": "success",
|
||||||
|
"merge_error": null,
|
||||||
|
"operation_status": "success",
|
||||||
|
"target_entity": "Elon Musk",
|
||||||
|
"final_entity": "Elon Musk",
|
||||||
|
"renamed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Example Response (partial success - update succeeded but merge failed):
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Entity updated successfully",
|
||||||
|
"data": { ... }, # Data reflects updated "Elon Msk" entity
|
||||||
|
"operation_summary": {
|
||||||
|
"merged": false,
|
||||||
|
"merge_status": "failed",
|
||||||
|
"merge_error": "Target entity locked by another operation",
|
||||||
|
"operation_status": "partial_success",
|
||||||
|
"target_entity": "Elon Musk",
|
||||||
|
"final_entity": "Elon Msk", # Original entity still exists
|
||||||
|
"renamed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await rag.aedit_entity(
|
result = await rag.aedit_entity(
|
||||||
entity_name=request.entity_name,
|
entity_name=request.entity_name,
|
||||||
updated_data=request.updated_data,
|
updated_data=request.updated_data,
|
||||||
allow_rename=request.allow_rename,
|
allow_rename=request.allow_rename,
|
||||||
|
allow_merge=request.allow_merge,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract operation_summary from result, with fallback for backward compatibility
|
||||||
|
operation_summary = result.get(
|
||||||
|
"operation_summary",
|
||||||
|
{
|
||||||
|
"merged": False,
|
||||||
|
"merge_status": "not_attempted",
|
||||||
|
"merge_error": None,
|
||||||
|
"operation_status": "success",
|
||||||
|
"target_entity": None,
|
||||||
|
"final_entity": request.updated_data.get(
|
||||||
|
"entity_name", request.entity_name
|
||||||
|
),
|
||||||
|
"renamed": request.updated_data.get(
|
||||||
|
"entity_name", request.entity_name
|
||||||
|
)
|
||||||
|
!= request.entity_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Separate entity data from operation_summary for clean response
|
||||||
|
entity_data = dict(result)
|
||||||
|
entity_data.pop("operation_summary", None)
|
||||||
|
|
||||||
|
# Generate appropriate response message based on merge status
|
||||||
|
response_message = (
|
||||||
|
f"Entity merged successfully into '{operation_summary['final_entity']}'"
|
||||||
|
if operation_summary.get("merged")
|
||||||
|
else "Entity updated successfully"
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Entity updated successfully",
|
"message": response_message,
|
||||||
"data": result,
|
"data": entity_data,
|
||||||
|
"operation_summary": operation_summary,
|
||||||
}
|
}
|
||||||
except ValueError as ve:
|
except ValueError as ve:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -365,7 +522,7 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
|
||||||
|
|
||||||
This endpoint establishes an undirected relationship between two existing entities.
|
This endpoint establishes an undirected relationship between two existing entities.
|
||||||
The provided source/target order is accepted for convenience, but the backend
|
The provided source/target order is accepted for convenience, but the backend
|
||||||
stored edge is undirected and may be returned with the entities swapped.
|
stored edge is undirected and may be returned with the entities swapped.
|
||||||
Both entities must already exist in the knowledge graph. The system automatically
|
Both entities must already exist in the knowledge graph. The system automatically
|
||||||
generates vector embeddings for the relationship to enable semantic search and graph traversal.
|
generates vector embeddings for the relationship to enable semantic search and graph traversal.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from typing import (
|
||||||
Dict,
|
Dict,
|
||||||
)
|
)
|
||||||
from lightrag.prompt import PROMPTS
|
from lightrag.prompt import PROMPTS
|
||||||
|
from lightrag.exceptions import PipelineCancelledException
|
||||||
from lightrag.constants import (
|
from lightrag.constants import (
|
||||||
DEFAULT_MAX_GLEANING,
|
DEFAULT_MAX_GLEANING,
|
||||||
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
|
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
|
||||||
|
|
@ -1603,6 +1604,7 @@ class LightRAG:
|
||||||
"batchs": 0, # Total number of files to be processed
|
"batchs": 0, # Total number of files to be processed
|
||||||
"cur_batch": 0, # Number of files already processed
|
"cur_batch": 0, # Number of files already processed
|
||||||
"request_pending": False, # Clear any previous request
|
"request_pending": False, # Clear any previous request
|
||||||
|
"cancellation_requested": False, # Initialize cancellation flag
|
||||||
"latest_message": "",
|
"latest_message": "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1619,6 +1621,22 @@ class LightRAG:
|
||||||
try:
|
try:
|
||||||
# Process documents until no more documents or requests
|
# Process documents until no more documents or requests
|
||||||
while True:
|
while True:
|
||||||
|
# Check for cancellation request at the start of main loop
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
# Clear pending request
|
||||||
|
pipeline_status["request_pending"] = False
|
||||||
|
# Celar cancellation flag
|
||||||
|
pipeline_status["cancellation_requested"] = False
|
||||||
|
|
||||||
|
log_message = "Pipeline cancelled by user"
|
||||||
|
logger.info(log_message)
|
||||||
|
pipeline_status["latest_message"] = log_message
|
||||||
|
pipeline_status["history_messages"].append(log_message)
|
||||||
|
|
||||||
|
# Exit directly, skipping request_pending check
|
||||||
|
return
|
||||||
|
|
||||||
if not to_process_docs:
|
if not to_process_docs:
|
||||||
log_message = "All enqueued documents have been processed"
|
log_message = "All enqueued documents have been processed"
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
|
|
@ -1681,14 +1699,25 @@ class LightRAG:
|
||||||
semaphore: asyncio.Semaphore,
|
semaphore: asyncio.Semaphore,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process single document"""
|
"""Process single document"""
|
||||||
|
# Initialize variables at the start to prevent UnboundLocalError in error handling
|
||||||
|
file_path = "unknown_source"
|
||||||
|
current_file_number = 0
|
||||||
file_extraction_stage_ok = False
|
file_extraction_stage_ok = False
|
||||||
|
processing_start_time = int(time.time())
|
||||||
|
first_stage_tasks = []
|
||||||
|
entity_relation_task = None
|
||||||
|
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
nonlocal processed_count
|
nonlocal processed_count
|
||||||
current_file_number = 0
|
|
||||||
# Initialize to prevent UnboundLocalError in error handling
|
# Initialize to prevent UnboundLocalError in error handling
|
||||||
first_stage_tasks = []
|
first_stage_tasks = []
|
||||||
entity_relation_task = None
|
entity_relation_task = None
|
||||||
try:
|
try:
|
||||||
|
# Check for cancellation before starting document processing
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException("User cancelled")
|
||||||
|
|
||||||
# Get file path from status document
|
# Get file path from status document
|
||||||
file_path = getattr(
|
file_path = getattr(
|
||||||
status_doc, "file_path", "unknown_source"
|
status_doc, "file_path", "unknown_source"
|
||||||
|
|
@ -1751,6 +1780,11 @@ class LightRAG:
|
||||||
# Record processing start time
|
# Record processing start time
|
||||||
processing_start_time = int(time.time())
|
processing_start_time = int(time.time())
|
||||||
|
|
||||||
|
# Check for cancellation before entity extraction
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException("User cancelled")
|
||||||
|
|
||||||
# Process document in two stages
|
# Process document in two stages
|
||||||
# Stage 1: Process text chunks and docs (parallel execution)
|
# Stage 1: Process text chunks and docs (parallel execution)
|
||||||
doc_status_task = asyncio.create_task(
|
doc_status_task = asyncio.create_task(
|
||||||
|
|
@ -1805,16 +1839,29 @@ class LightRAG:
|
||||||
file_extraction_stage_ok = True
|
file_extraction_stage_ok = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error and update pipeline status
|
# Check if this is a user cancellation
|
||||||
logger.error(traceback.format_exc())
|
if isinstance(e, PipelineCancelledException):
|
||||||
error_msg = f"Failed to extract document {current_file_number}/{total_files}: {file_path}"
|
# User cancellation - log brief message only, no traceback
|
||||||
logger.error(error_msg)
|
error_msg = f"User cancelled {current_file_number}/{total_files}: {file_path}"
|
||||||
async with pipeline_status_lock:
|
logger.warning(error_msg)
|
||||||
pipeline_status["latest_message"] = error_msg
|
async with pipeline_status_lock:
|
||||||
pipeline_status["history_messages"].append(
|
pipeline_status["latest_message"] = error_msg
|
||||||
traceback.format_exc()
|
pipeline_status["history_messages"].append(
|
||||||
)
|
error_msg
|
||||||
pipeline_status["history_messages"].append(error_msg)
|
)
|
||||||
|
else:
|
||||||
|
# Other exceptions - log with traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
error_msg = f"Failed to extract document {current_file_number}/{total_files}: {file_path}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
pipeline_status["latest_message"] = error_msg
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
traceback.format_exc()
|
||||||
|
)
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
error_msg
|
||||||
|
)
|
||||||
|
|
||||||
# Cancel tasks that are not yet completed
|
# Cancel tasks that are not yet completed
|
||||||
all_tasks = first_stage_tasks + (
|
all_tasks = first_stage_tasks + (
|
||||||
|
|
@ -1824,9 +1871,14 @@ class LightRAG:
|
||||||
if task and not task.done():
|
if task and not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
# Persistent llm cache
|
# Persistent llm cache with error handling
|
||||||
if self.llm_response_cache:
|
if self.llm_response_cache:
|
||||||
await self.llm_response_cache.index_done_callback()
|
try:
|
||||||
|
await self.llm_response_cache.index_done_callback()
|
||||||
|
except Exception as persist_error:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to persist LLM cache: {persist_error}"
|
||||||
|
)
|
||||||
|
|
||||||
# Record processing end time for failed case
|
# Record processing end time for failed case
|
||||||
processing_end_time = int(time.time())
|
processing_end_time = int(time.time())
|
||||||
|
|
@ -1856,6 +1908,15 @@ class LightRAG:
|
||||||
# Concurrency is controlled by keyed lock for individual entities and relationships
|
# Concurrency is controlled by keyed lock for individual entities and relationships
|
||||||
if file_extraction_stage_ok:
|
if file_extraction_stage_ok:
|
||||||
try:
|
try:
|
||||||
|
# Check for cancellation before merge
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get(
|
||||||
|
"cancellation_requested", False
|
||||||
|
):
|
||||||
|
raise PipelineCancelledException(
|
||||||
|
"User cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
# Get chunk_results from entity_relation_task
|
# Get chunk_results from entity_relation_task
|
||||||
chunk_results = await entity_relation_task
|
chunk_results = await entity_relation_task
|
||||||
await merge_nodes_and_edges(
|
await merge_nodes_and_edges(
|
||||||
|
|
@ -1914,22 +1975,38 @@ class LightRAG:
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error and update pipeline status
|
# Check if this is a user cancellation
|
||||||
logger.error(traceback.format_exc())
|
if isinstance(e, PipelineCancelledException):
|
||||||
error_msg = f"Merging stage failed in document {current_file_number}/{total_files}: {file_path}"
|
# User cancellation - log brief message only, no traceback
|
||||||
logger.error(error_msg)
|
error_msg = f"User cancelled during merge {current_file_number}/{total_files}: {file_path}"
|
||||||
async with pipeline_status_lock:
|
logger.warning(error_msg)
|
||||||
pipeline_status["latest_message"] = error_msg
|
async with pipeline_status_lock:
|
||||||
pipeline_status["history_messages"].append(
|
pipeline_status["latest_message"] = error_msg
|
||||||
traceback.format_exc()
|
pipeline_status["history_messages"].append(
|
||||||
)
|
error_msg
|
||||||
pipeline_status["history_messages"].append(
|
)
|
||||||
error_msg
|
else:
|
||||||
)
|
# Other exceptions - log with traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
error_msg = f"Merging stage failed in document {current_file_number}/{total_files}: {file_path}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
pipeline_status["latest_message"] = error_msg
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
traceback.format_exc()
|
||||||
|
)
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
error_msg
|
||||||
|
)
|
||||||
|
|
||||||
# Persistent llm cache
|
# Persistent llm cache with error handling
|
||||||
if self.llm_response_cache:
|
if self.llm_response_cache:
|
||||||
await self.llm_response_cache.index_done_callback()
|
try:
|
||||||
|
await self.llm_response_cache.index_done_callback()
|
||||||
|
except Exception as persist_error:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to persist LLM cache: {persist_error}"
|
||||||
|
)
|
||||||
|
|
||||||
# Record processing end time for failed case
|
# Record processing end time for failed case
|
||||||
processing_end_time = int(time.time())
|
processing_end_time = int(time.time())
|
||||||
|
|
@ -1970,7 +2047,19 @@ class LightRAG:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for all document processing to complete
|
# Wait for all document processing to complete
|
||||||
await asyncio.gather(*doc_tasks)
|
try:
|
||||||
|
await asyncio.gather(*doc_tasks)
|
||||||
|
except PipelineCancelledException:
|
||||||
|
# Cancel all remaining tasks
|
||||||
|
for task in doc_tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Wait for all tasks to complete cancellation
|
||||||
|
await asyncio.wait(doc_tasks, return_when=asyncio.ALL_COMPLETED)
|
||||||
|
|
||||||
|
# Exit directly (document statuses already updated in process_document)
|
||||||
|
return
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -2001,11 +2090,14 @@ class LightRAG:
|
||||||
to_process_docs.update(pending_docs)
|
to_process_docs.update(pending_docs)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
log_message = "Enqueued document processing pipeline stoped"
|
log_message = "Enqueued document processing pipeline stopped"
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
# Always reset busy status when done or if an exception occurs (with lock)
|
# Always reset busy status and cancellation flag when done or if an exception occurs (with lock)
|
||||||
async with pipeline_status_lock:
|
async with pipeline_status_lock:
|
||||||
pipeline_status["busy"] = False
|
pipeline_status["busy"] = False
|
||||||
|
pipeline_status["cancellation_requested"] = (
|
||||||
|
False # Always reset cancellation flag
|
||||||
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -3210,6 +3302,10 @@ class LightRAG:
|
||||||
list(entities_to_delete)
|
list(entities_to_delete)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Delete from entity_chunks storage
|
||||||
|
if self.entity_chunks:
|
||||||
|
await self.entity_chunks.delete(list(entities_to_delete))
|
||||||
|
|
||||||
async with pipeline_status_lock:
|
async with pipeline_status_lock:
|
||||||
log_message = f"Successfully deleted {len(entities_to_delete)} entities"
|
log_message = f"Successfully deleted {len(entities_to_delete)} entities"
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
|
|
@ -3239,6 +3335,14 @@ class LightRAG:
|
||||||
list(relationships_to_delete)
|
list(relationships_to_delete)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Delete from relation_chunks storage
|
||||||
|
if self.relation_chunks:
|
||||||
|
relation_storage_keys = [
|
||||||
|
make_relation_chunk_key(src, tgt)
|
||||||
|
for src, tgt in relationships_to_delete
|
||||||
|
]
|
||||||
|
await self.relation_chunks.delete(relation_storage_keys)
|
||||||
|
|
||||||
async with pipeline_status_lock:
|
async with pipeline_status_lock:
|
||||||
log_message = f"Successfully deleted {len(relationships_to_delete)} relations"
|
log_message = f"Successfully deleted {len(relationships_to_delete)} relations"
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
|
|
@ -3302,9 +3406,7 @@ class LightRAG:
|
||||||
pipeline_status["history_messages"].append(cache_log_message)
|
pipeline_status["history_messages"].append(cache_log_message)
|
||||||
log_message = cache_log_message
|
log_message = cache_log_message
|
||||||
except Exception as cache_delete_error:
|
except Exception as cache_delete_error:
|
||||||
log_message = (
|
log_message = f"Failed to delete LLM cache for document {doc_id}: {cache_delete_error}"
|
||||||
f"Failed to delete LLM cache for document {doc_id}: {cache_delete_error}"
|
|
||||||
)
|
|
||||||
logger.error(log_message)
|
logger.error(log_message)
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
async with pipeline_status_lock:
|
async with pipeline_status_lock:
|
||||||
|
|
@ -3475,16 +3577,22 @@ class LightRAG:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aedit_entity(
|
async def aedit_entity(
|
||||||
self, entity_name: str, updated_data: dict[str, str], allow_rename: bool = True
|
self,
|
||||||
|
entity_name: str,
|
||||||
|
updated_data: dict[str, str],
|
||||||
|
allow_rename: bool = True,
|
||||||
|
allow_merge: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Asynchronously edit entity information.
|
"""Asynchronously edit entity information.
|
||||||
|
|
||||||
Updates entity information in the knowledge graph and re-embeds the entity in the vector database.
|
Updates entity information in the knowledge graph and re-embeds the entity in the vector database.
|
||||||
|
Also synchronizes entity_chunks_storage and relation_chunks_storage to track chunk references.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entity_name: Name of the entity to edit
|
entity_name: Name of the entity to edit
|
||||||
updated_data: Dictionary containing updated attributes, e.g. {"description": "new description", "entity_type": "new type"}
|
updated_data: Dictionary containing updated attributes, e.g. {"description": "new description", "entity_type": "new type"}
|
||||||
allow_rename: Whether to allow entity renaming, defaults to True
|
allow_rename: Whether to allow entity renaming, defaults to True
|
||||||
|
allow_merge: Whether to merge into an existing entity when renaming to an existing name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing updated entity information
|
Dictionary containing updated entity information
|
||||||
|
|
@ -3498,14 +3606,21 @@ class LightRAG:
|
||||||
entity_name,
|
entity_name,
|
||||||
updated_data,
|
updated_data,
|
||||||
allow_rename,
|
allow_rename,
|
||||||
|
allow_merge,
|
||||||
|
self.entity_chunks,
|
||||||
|
self.relation_chunks,
|
||||||
)
|
)
|
||||||
|
|
||||||
def edit_entity(
|
def edit_entity(
|
||||||
self, entity_name: str, updated_data: dict[str, str], allow_rename: bool = True
|
self,
|
||||||
|
entity_name: str,
|
||||||
|
updated_data: dict[str, str],
|
||||||
|
allow_rename: bool = True,
|
||||||
|
allow_merge: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
loop = always_get_an_event_loop()
|
loop = always_get_an_event_loop()
|
||||||
return loop.run_until_complete(
|
return loop.run_until_complete(
|
||||||
self.aedit_entity(entity_name, updated_data, allow_rename)
|
self.aedit_entity(entity_name, updated_data, allow_rename, allow_merge)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aedit_relation(
|
async def aedit_relation(
|
||||||
|
|
@ -3514,6 +3629,7 @@ class LightRAG:
|
||||||
"""Asynchronously edit relation information.
|
"""Asynchronously edit relation information.
|
||||||
|
|
||||||
Updates relation (edge) information in the knowledge graph and re-embeds the relation in the vector database.
|
Updates relation (edge) information in the knowledge graph and re-embeds the relation in the vector database.
|
||||||
|
Also synchronizes the relation_chunks_storage to track which chunks reference this relation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_entity: Name of the source entity
|
source_entity: Name of the source entity
|
||||||
|
|
@ -3532,6 +3648,7 @@ class LightRAG:
|
||||||
source_entity,
|
source_entity,
|
||||||
target_entity,
|
target_entity,
|
||||||
updated_data,
|
updated_data,
|
||||||
|
self.relation_chunks,
|
||||||
)
|
)
|
||||||
|
|
||||||
def edit_relation(
|
def edit_relation(
|
||||||
|
|
@ -3643,6 +3760,8 @@ class LightRAG:
|
||||||
target_entity,
|
target_entity,
|
||||||
merge_strategy,
|
merge_strategy,
|
||||||
target_entity_data,
|
target_entity_data,
|
||||||
|
self.entity_chunks,
|
||||||
|
self.relation_chunks,
|
||||||
)
|
)
|
||||||
|
|
||||||
def merge_entities(
|
def merge_entities(
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,33 @@ async def aedit_entity(
|
||||||
relation_chunks_storage: Optional KV storage for tracking chunks that reference relations
|
relation_chunks_storage: Optional KV storage for tracking chunks that reference relations
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing updated entity information
|
Dictionary containing updated entity information and operation summary with the following structure:
|
||||||
|
{
|
||||||
|
"entity_name": str, # Name of the entity
|
||||||
|
"description": str, # Entity description
|
||||||
|
"entity_type": str, # Entity type
|
||||||
|
"source_id": str, # Source chunk IDs
|
||||||
|
... # Other entity properties
|
||||||
|
"operation_summary": {
|
||||||
|
"merged": bool, # Whether entity was merged
|
||||||
|
"merge_status": str, # "success" | "failed" | "not_attempted"
|
||||||
|
"merge_error": str | None, # Error message if merge failed
|
||||||
|
"operation_status": str, # "success" | "partial_success" | "failure"
|
||||||
|
"target_entity": str | None, # Target entity name if renaming/merging
|
||||||
|
"final_entity": str, # Final entity name after operation
|
||||||
|
"renamed": bool # Whether entity was renamed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
operation_status values:
|
||||||
|
- "success": Operation completed successfully (update/rename/merge all succeeded)
|
||||||
|
- "partial_success": Non-name updates succeeded but merge failed
|
||||||
|
- "failure": Operation failed completely
|
||||||
|
|
||||||
|
merge_status values:
|
||||||
|
- "success": Entity successfully merged into target
|
||||||
|
- "failed": Merge operation failed
|
||||||
|
- "not_attempted": No merge was attempted (normal update/rename)
|
||||||
"""
|
"""
|
||||||
new_entity_name = updated_data.get("entity_name", entity_name)
|
new_entity_name = updated_data.get("entity_name", entity_name)
|
||||||
is_renaming = new_entity_name != entity_name
|
is_renaming = new_entity_name != entity_name
|
||||||
|
|
@ -549,6 +575,16 @@ async def aedit_entity(
|
||||||
|
|
||||||
workspace = entities_vdb.global_config.get("workspace", "")
|
workspace = entities_vdb.global_config.get("workspace", "")
|
||||||
namespace = f"{workspace}:GraphDB" if workspace else "GraphDB"
|
namespace = f"{workspace}:GraphDB" if workspace else "GraphDB"
|
||||||
|
|
||||||
|
operation_summary: dict[str, Any] = {
|
||||||
|
"merged": False,
|
||||||
|
"merge_status": "not_attempted",
|
||||||
|
"merge_error": None,
|
||||||
|
"operation_status": "success",
|
||||||
|
"target_entity": None,
|
||||||
|
"final_entity": new_entity_name if is_renaming else entity_name,
|
||||||
|
"renamed": is_renaming,
|
||||||
|
}
|
||||||
async with get_storage_keyed_lock(
|
async with get_storage_keyed_lock(
|
||||||
lock_keys, namespace=namespace, enable_logging=False
|
lock_keys, namespace=namespace, enable_logging=False
|
||||||
):
|
):
|
||||||
|
|
@ -572,38 +608,93 @@ async def aedit_entity(
|
||||||
f"Entity Edit: `{entity_name}` will be merged into `{new_entity_name}`"
|
f"Entity Edit: `{entity_name}` will be merged into `{new_entity_name}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track whether non-name updates were applied
|
||||||
|
non_name_updates_applied = False
|
||||||
non_name_updates = {
|
non_name_updates = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in updated_data.items()
|
for key, value in updated_data.items()
|
||||||
if key != "entity_name"
|
if key != "entity_name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Apply non-name updates first
|
||||||
if non_name_updates:
|
if non_name_updates:
|
||||||
logger.info(
|
try:
|
||||||
"Entity Edit: applying non-name updates before merge"
|
logger.info(
|
||||||
)
|
"Entity Edit: applying non-name updates before merge"
|
||||||
await _edit_entity_impl(
|
)
|
||||||
|
await _edit_entity_impl(
|
||||||
|
chunk_entity_relation_graph,
|
||||||
|
entities_vdb,
|
||||||
|
relationships_vdb,
|
||||||
|
entity_name,
|
||||||
|
non_name_updates,
|
||||||
|
entity_chunks_storage=entity_chunks_storage,
|
||||||
|
relation_chunks_storage=relation_chunks_storage,
|
||||||
|
)
|
||||||
|
non_name_updates_applied = True
|
||||||
|
except Exception as update_error:
|
||||||
|
# If update fails, re-raise immediately
|
||||||
|
logger.error(
|
||||||
|
f"Entity Edit: non-name updates failed: {update_error}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Attempt to merge entities
|
||||||
|
try:
|
||||||
|
merge_result = await _merge_entities_impl(
|
||||||
chunk_entity_relation_graph,
|
chunk_entity_relation_graph,
|
||||||
entities_vdb,
|
entities_vdb,
|
||||||
relationships_vdb,
|
relationships_vdb,
|
||||||
entity_name,
|
[entity_name],
|
||||||
non_name_updates,
|
new_entity_name,
|
||||||
|
merge_strategy=None,
|
||||||
|
target_entity_data=None,
|
||||||
entity_chunks_storage=entity_chunks_storage,
|
entity_chunks_storage=entity_chunks_storage,
|
||||||
relation_chunks_storage=relation_chunks_storage,
|
relation_chunks_storage=relation_chunks_storage,
|
||||||
)
|
)
|
||||||
|
|
||||||
return await _merge_entities_impl(
|
# Merge succeeded
|
||||||
chunk_entity_relation_graph,
|
operation_summary.update(
|
||||||
entities_vdb,
|
{
|
||||||
relationships_vdb,
|
"merged": True,
|
||||||
[entity_name],
|
"merge_status": "success",
|
||||||
new_entity_name,
|
"merge_error": None,
|
||||||
merge_strategy=None,
|
"operation_status": "success",
|
||||||
target_entity_data=None,
|
"target_entity": new_entity_name,
|
||||||
entity_chunks_storage=entity_chunks_storage,
|
"final_entity": new_entity_name,
|
||||||
relation_chunks_storage=relation_chunks_storage,
|
}
|
||||||
)
|
)
|
||||||
|
return {**merge_result, "operation_summary": operation_summary}
|
||||||
|
|
||||||
return await _edit_entity_impl(
|
except Exception as merge_error:
|
||||||
|
# Merge failed, but update may have succeeded
|
||||||
|
logger.error(f"Entity Edit: merge failed: {merge_error}")
|
||||||
|
|
||||||
|
# Return partial success status (update succeeded but merge failed)
|
||||||
|
operation_summary.update(
|
||||||
|
{
|
||||||
|
"merged": False,
|
||||||
|
"merge_status": "failed",
|
||||||
|
"merge_error": str(merge_error),
|
||||||
|
"operation_status": "partial_success"
|
||||||
|
if non_name_updates_applied
|
||||||
|
else "failure",
|
||||||
|
"target_entity": new_entity_name,
|
||||||
|
"final_entity": entity_name, # Keep source entity name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get current entity info (with applied updates if any)
|
||||||
|
entity_info = await get_entity_info(
|
||||||
|
chunk_entity_relation_graph,
|
||||||
|
entities_vdb,
|
||||||
|
entity_name,
|
||||||
|
include_vector_data=True,
|
||||||
|
)
|
||||||
|
return {**entity_info, "operation_summary": operation_summary}
|
||||||
|
|
||||||
|
# Normal edit flow (no merge involved)
|
||||||
|
edit_result = await _edit_entity_impl(
|
||||||
chunk_entity_relation_graph,
|
chunk_entity_relation_graph,
|
||||||
entities_vdb,
|
entities_vdb,
|
||||||
relationships_vdb,
|
relationships_vdb,
|
||||||
|
|
@ -612,6 +703,9 @@ async def aedit_entity(
|
||||||
entity_chunks_storage=entity_chunks_storage,
|
entity_chunks_storage=entity_chunks_storage,
|
||||||
relation_chunks_storage=relation_chunks_storage,
|
relation_chunks_storage=relation_chunks_storage,
|
||||||
)
|
)
|
||||||
|
operation_summary["operation_status"] = "success"
|
||||||
|
return {**edit_result, "operation_summary": operation_summary}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error while editing entity '{entity_name}': {e}")
|
logger.error(f"Error while editing entity '{entity_name}': {e}")
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,21 @@ export type QueryResponse = {
|
||||||
response: string
|
response: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EntityUpdateResponse = {
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
data: Record<string, any>
|
||||||
|
operation_summary?: {
|
||||||
|
merged: boolean
|
||||||
|
merge_status: 'success' | 'failed' | 'not_attempted'
|
||||||
|
merge_error: string | null
|
||||||
|
operation_status: 'success' | 'partial_success' | 'failure'
|
||||||
|
target_entity: string | null
|
||||||
|
final_entity?: string | null
|
||||||
|
renamed?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type DocActionResponse = {
|
export type DocActionResponse = {
|
||||||
status: 'success' | 'partial_success' | 'failure' | 'duplicated'
|
status: 'success' | 'partial_success' | 'failure' | 'duplicated'
|
||||||
message: string
|
message: string
|
||||||
|
|
@ -716,17 +731,20 @@ export const loginToServer = async (username: string, password: string): Promise
|
||||||
* @param entityName The name of the entity to update
|
* @param entityName The name of the entity to update
|
||||||
* @param updatedData Dictionary containing updated attributes
|
* @param updatedData Dictionary containing updated attributes
|
||||||
* @param allowRename Whether to allow renaming the entity (default: false)
|
* @param allowRename Whether to allow renaming the entity (default: false)
|
||||||
|
* @param allowMerge Whether to merge into an existing entity when renaming to a duplicate name
|
||||||
* @returns Promise with the updated entity information
|
* @returns Promise with the updated entity information
|
||||||
*/
|
*/
|
||||||
export const updateEntity = async (
|
export const updateEntity = async (
|
||||||
entityName: string,
|
entityName: string,
|
||||||
updatedData: Record<string, any>,
|
updatedData: Record<string, any>,
|
||||||
allowRename: boolean = false
|
allowRename: boolean = false,
|
||||||
): Promise<DocActionResponse> => {
|
allowMerge: boolean = false
|
||||||
|
): Promise<EntityUpdateResponse> => {
|
||||||
const response = await axiosInstance.post('/graph/entity/edit', {
|
const response = await axiosInstance.post('/graph/entity/edit', {
|
||||||
entity_name: entityName,
|
entity_name: entityName,
|
||||||
updated_data: updatedData,
|
updated_data: updatedData,
|
||||||
allow_rename: allowRename
|
allow_rename: allowRename,
|
||||||
|
allow_merge: allowMerge
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
|
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/Dialog'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
|
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
|
||||||
import PropertyEditDialog from './PropertyEditDialog'
|
import PropertyEditDialog from './PropertyEditDialog'
|
||||||
|
|
||||||
|
|
@ -48,6 +58,12 @@ const EditablePropertyRow = ({
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [currentValue, setCurrentValue] = useState(initialValue)
|
const [currentValue, setCurrentValue] = useState(initialValue)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [mergeDialogOpen, setMergeDialogOpen] = useState(false)
|
||||||
|
const [mergeDialogInfo, setMergeDialogInfo] = useState<{
|
||||||
|
targetEntity: string
|
||||||
|
sourceEntity: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentValue(initialValue)
|
setCurrentValue(initialValue)
|
||||||
|
|
@ -56,42 +72,111 @@ const EditablePropertyRow = ({
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
if (isEditable && !isEditing) {
|
if (isEditable && !isEditing) {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
|
setErrorMessage(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
|
setErrorMessage(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (value: string) => {
|
const handleSave = async (value: string, options?: { allowMerge?: boolean }) => {
|
||||||
if (isSubmitting || value === String(currentValue)) {
|
if (isSubmitting || value === String(currentValue)) {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
|
setErrorMessage(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
setErrorMessage(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (entityType === 'node' && entityId && nodeId) {
|
if (entityType === 'node' && entityId && nodeId) {
|
||||||
let updatedData = { [name]: value }
|
let updatedData = { [name]: value }
|
||||||
|
const allowMerge = options?.allowMerge ?? false
|
||||||
|
|
||||||
if (name === 'entity_id') {
|
if (name === 'entity_id') {
|
||||||
const exists = await checkEntityNameExists(value)
|
if (!allowMerge) {
|
||||||
if (exists) {
|
const exists = await checkEntityNameExists(value)
|
||||||
toast.error(t('graphPanel.propertiesView.errors.duplicateName'))
|
if (exists) {
|
||||||
return
|
const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')
|
||||||
|
setErrorMessage(errorMsg)
|
||||||
|
toast.error(errorMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updatedData = { 'entity_name': value }
|
updatedData = { 'entity_name': value }
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateEntity(entityId, updatedData, true)
|
const response = await updateEntity(entityId, updatedData, true, allowMerge)
|
||||||
try {
|
const operationSummary = response.operation_summary
|
||||||
await useGraphStore.getState().updateNodeAndSelect(nodeId, entityId, name, value)
|
const operationStatus = operationSummary?.operation_status || 'complete_success'
|
||||||
} catch (error) {
|
const finalValue = operationSummary?.final_entity ?? value
|
||||||
console.error('Error updating node in graph:', error)
|
|
||||||
throw new Error('Failed to update node in graph')
|
// Handle different operation statuses
|
||||||
|
if (operationStatus === 'success') {
|
||||||
|
if (operationSummary?.merged) {
|
||||||
|
// Node was successfully merged into an existing entity
|
||||||
|
setMergeDialogInfo({
|
||||||
|
targetEntity: finalValue,
|
||||||
|
sourceEntity: entityId,
|
||||||
|
})
|
||||||
|
setMergeDialogOpen(true)
|
||||||
|
toast.success(t('graphPanel.propertiesView.success.entityMerged'))
|
||||||
|
} else {
|
||||||
|
// Node was updated/renamed normally
|
||||||
|
try {
|
||||||
|
await useGraphStore
|
||||||
|
.getState()
|
||||||
|
.updateNodeAndSelect(nodeId, entityId, name, finalValue)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating node in graph:', error)
|
||||||
|
throw new Error('Failed to update node in graph')
|
||||||
|
}
|
||||||
|
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state and notify parent component
|
||||||
|
// For entity_id updates, use finalValue (which may be different due to merging)
|
||||||
|
// For other properties, use the original value the user entered
|
||||||
|
const valueToSet = name === 'entity_id' ? finalValue : value
|
||||||
|
setCurrentValue(valueToSet)
|
||||||
|
onValueChange?.(valueToSet)
|
||||||
|
|
||||||
|
} else if (operationStatus === 'partial_success') {
|
||||||
|
// Partial success: update succeeded but merge failed
|
||||||
|
// Do NOT update graph data to keep frontend in sync with backend
|
||||||
|
const mergeError = operationSummary?.merge_error || 'Unknown error'
|
||||||
|
|
||||||
|
const errorMsg = t('graphPanel.propertiesView.errors.updateSuccessButMergeFailed', {
|
||||||
|
error: mergeError
|
||||||
|
})
|
||||||
|
setErrorMessage(errorMsg)
|
||||||
|
toast.error(errorMsg)
|
||||||
|
// Do not update currentValue or call onValueChange
|
||||||
|
return
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Complete failure or unknown status
|
||||||
|
// Check if this was a merge attempt or just a regular update
|
||||||
|
if (operationSummary?.merge_status === 'failed') {
|
||||||
|
// Merge operation was attempted but failed
|
||||||
|
const mergeError = operationSummary?.merge_error || 'Unknown error'
|
||||||
|
const errorMsg = t('graphPanel.propertiesView.errors.mergeFailed', {
|
||||||
|
error: mergeError
|
||||||
|
})
|
||||||
|
setErrorMessage(errorMsg)
|
||||||
|
toast.error(errorMsg)
|
||||||
|
} else {
|
||||||
|
// Regular update failed (no merge involved)
|
||||||
|
const errorMsg = t('graphPanel.propertiesView.errors.updateFailed')
|
||||||
|
setErrorMessage(errorMsg)
|
||||||
|
toast.error(errorMsg)
|
||||||
|
}
|
||||||
|
// Do not update currentValue or call onValueChange
|
||||||
|
return
|
||||||
}
|
}
|
||||||
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
|
|
||||||
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
|
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
|
||||||
const updatedData = { [name]: value }
|
const updatedData = { [name]: value }
|
||||||
await updateRelation(sourceId, targetId, updatedData)
|
await updateRelation(sourceId, targetId, updatedData)
|
||||||
|
|
@ -102,19 +187,42 @@ const EditablePropertyRow = ({
|
||||||
throw new Error('Failed to update edge in graph')
|
throw new Error('Failed to update edge in graph')
|
||||||
}
|
}
|
||||||
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
|
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
|
||||||
|
setCurrentValue(value)
|
||||||
|
onValueChange?.(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
setCurrentValue(value)
|
|
||||||
onValueChange?.(value)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating property:', error)
|
console.error('Error updating property:', error)
|
||||||
toast.error(t('graphPanel.propertiesView.errors.updateFailed'))
|
const errorMsg = error instanceof Error ? error.message : t('graphPanel.propertiesView.errors.updateFailed')
|
||||||
|
setErrorMessage(errorMsg)
|
||||||
|
toast.error(errorMsg)
|
||||||
|
return
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMergeRefresh = (useMergedStart: boolean) => {
|
||||||
|
const info = mergeDialogInfo
|
||||||
|
const graphState = useGraphStore.getState()
|
||||||
|
const settingsState = useSettingsStore.getState()
|
||||||
|
|
||||||
|
graphState.clearSelection()
|
||||||
|
graphState.setGraphDataFetchAttempted(false)
|
||||||
|
graphState.setLastSuccessfulQueryLabel('')
|
||||||
|
|
||||||
|
if (useMergedStart && info?.targetEntity) {
|
||||||
|
settingsState.setQueryLabel(info.targetEntity)
|
||||||
|
} else {
|
||||||
|
graphState.incrementGraphDataVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMergeDialogOpen(false)
|
||||||
|
setMergeDialogInfo(null)
|
||||||
|
toast.info(t('graphPanel.propertiesView.mergeDialog.refreshing'))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 overflow-hidden">
|
<div className="flex items-center gap-1 overflow-hidden">
|
||||||
<PropertyName name={name} />
|
<PropertyName name={name} />
|
||||||
|
|
@ -131,7 +239,45 @@ const EditablePropertyRow = ({
|
||||||
propertyName={name}
|
propertyName={name}
|
||||||
initialValue={String(currentValue)}
|
initialValue={String(currentValue)}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={mergeDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setMergeDialogOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setMergeDialogInfo(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('graphPanel.propertiesView.mergeDialog.title')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('graphPanel.propertiesView.mergeDialog.description', {
|
||||||
|
source: mergeDialogInfo?.sourceEntity ?? '',
|
||||||
|
target: mergeDialogInfo?.targetEntity ?? '',
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('graphPanel.propertiesView.mergeDialog.refreshHint')}
|
||||||
|
</p>
|
||||||
|
<DialogFooter className="mt-4 flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleMergeRefresh(false)}
|
||||||
|
>
|
||||||
|
{t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => handleMergeRefresh(true)}>
|
||||||
|
{t('graphPanel.propertiesView.mergeDialog.useMergedStart')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,16 @@ import {
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '@/components/ui/Dialog'
|
} from '@/components/ui/Dialog'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
|
import Checkbox from '@/components/ui/Checkbox'
|
||||||
|
|
||||||
interface PropertyEditDialogProps {
|
interface PropertyEditDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (value: string) => void
|
onSave: (value: string, options?: { allowMerge?: boolean }) => void
|
||||||
propertyName: string
|
propertyName: string
|
||||||
initialValue: string
|
initialValue: string
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
|
errorMessage?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,17 +31,18 @@ const PropertyEditDialog = ({
|
||||||
onSave,
|
onSave,
|
||||||
propertyName,
|
propertyName,
|
||||||
initialValue,
|
initialValue,
|
||||||
isSubmitting = false
|
isSubmitting = false,
|
||||||
|
errorMessage = null
|
||||||
}: PropertyEditDialogProps) => {
|
}: PropertyEditDialogProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
// Add error state to display save failure messages
|
const [allowMerge, setAllowMerge] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Initialize value when dialog opens
|
// Initialize value when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setValue(initialValue)
|
setValue(initialValue)
|
||||||
|
setAllowMerge(false)
|
||||||
}
|
}
|
||||||
}, [isOpen, initialValue])
|
}, [isOpen, initialValue])
|
||||||
|
|
||||||
|
|
@ -86,18 +89,8 @@ const PropertyEditDialog = ({
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (value.trim() !== '') {
|
if (value.trim() !== '') {
|
||||||
// Clear previous error messages
|
const options = propertyName === 'entity_id' ? { allowMerge } : undefined
|
||||||
setError(null)
|
await onSave(value, options)
|
||||||
try {
|
|
||||||
await onSave(value)
|
|
||||||
onClose()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Save error:', error)
|
|
||||||
// Set error message to state for UI display
|
|
||||||
setError(typeof error === 'object' && error !== null
|
|
||||||
? (error as Error).message || t('common.saveFailed')
|
|
||||||
: t('common.saveFailed'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,9 +109,9 @@ const PropertyEditDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Display error message if save fails */}
|
{/* Display error message if save fails */}
|
||||||
{error && (
|
{errorMessage && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm mt-2">
|
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
|
||||||
{error}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -146,6 +139,25 @@ const PropertyEditDialog = ({
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{propertyName === 'entity_id' && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<label className="flex items-start gap-2 text-sm font-medium">
|
||||||
|
<Checkbox
|
||||||
|
id="allow-merge"
|
||||||
|
checked={allowMerge}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onCheckedChange={(checked) => setAllowMerge(checked === true)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span>{t('graphPanel.propertiesView.mergeOptionLabel')}</span>
|
||||||
|
<p className="text-xs font-normal text-muted-foreground">
|
||||||
|
{t('graphPanel.propertiesView.mergeOptionDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "اسم العقدة موجود بالفعل",
|
"duplicateName": "اسم العقدة موجود بالفعل",
|
||||||
"updateFailed": "فشل تحديث العقدة",
|
"updateFailed": "فشل تحديث العقدة",
|
||||||
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا"
|
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
|
||||||
|
"updateSuccessButMergeFailed": "تم تحديث الخصائص، لكن الدمج فشل: {{error}}",
|
||||||
|
"mergeFailed": "فشل الدمج: {{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "تم تحديث العقدة بنجاح",
|
"entityUpdated": "تم تحديث العقدة بنجاح",
|
||||||
"relationUpdated": "تم تحديث العلاقة بنجاح"
|
"relationUpdated": "تم تحديث العلاقة بنجاح",
|
||||||
|
"entityMerged": "تم دمج العقد بنجاح"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "دمج تلقائي عند العثور على اسم مكرر",
|
||||||
|
"mergeOptionDescription": "عند التفعيل، سيتم دمج هذه العقدة تلقائيًا في العقدة الموجودة بدلاً من ظهور خطأ عند إعادة التسمية بنفس الاسم.",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "تم دمج العقدة",
|
||||||
|
"description": "\"{{source}}\" تم دمجها في \"{{target}}\".",
|
||||||
|
"refreshHint": "يجب تحديث الرسم البياني لتحميل البنية الأحدث.",
|
||||||
|
"keepCurrentStart": "تحديث مع الحفاظ على عقدة البدء الحالية",
|
||||||
|
"useMergedStart": "تحديث واستخدام العقدة المدمجة كنقطة بدء",
|
||||||
|
"refreshing": "جارٍ تحديث الرسم البياني..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "عقدة",
|
"title": "عقدة",
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "Node name already exists",
|
"duplicateName": "Node name already exists",
|
||||||
"updateFailed": "Failed to update node",
|
"updateFailed": "Failed to update node",
|
||||||
"tryAgainLater": "Please try again later"
|
"tryAgainLater": "Please try again later",
|
||||||
|
"updateSuccessButMergeFailed": "Properties updated, but merge failed: {{error}}",
|
||||||
|
"mergeFailed": "Merge failed: {{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "Node updated successfully",
|
"entityUpdated": "Node updated successfully",
|
||||||
"relationUpdated": "Relation updated successfully"
|
"relationUpdated": "Relation updated successfully",
|
||||||
|
"entityMerged": "Nodes merged successfully"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "Automatically merge when a duplicate name is found",
|
||||||
|
"mergeOptionDescription": "If enabled, renaming to an existing name will merge this node into the existing one instead of failing.",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "Node merged",
|
||||||
|
"description": "\"{{source}}\" has been merged into \"{{target}}\".",
|
||||||
|
"refreshHint": "Refresh the graph to load the latest structure.",
|
||||||
|
"keepCurrentStart": "Refresh and keep current start node",
|
||||||
|
"useMergedStart": "Refresh and use merged node",
|
||||||
|
"refreshing": "Refreshing graph..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "Node",
|
"title": "Node",
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "Le nom du nœud existe déjà",
|
"duplicateName": "Le nom du nœud existe déjà",
|
||||||
"updateFailed": "Échec de la mise à jour du nœud",
|
"updateFailed": "Échec de la mise à jour du nœud",
|
||||||
"tryAgainLater": "Veuillez réessayer plus tard"
|
"tryAgainLater": "Veuillez réessayer plus tard",
|
||||||
|
"updateSuccessButMergeFailed": "Propriétés mises à jour, mais la fusion a échoué : {{error}}",
|
||||||
|
"mergeFailed": "Échec de la fusion : {{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "Nœud mis à jour avec succès",
|
"entityUpdated": "Nœud mis à jour avec succès",
|
||||||
"relationUpdated": "Relation mise à jour avec succès"
|
"relationUpdated": "Relation mise à jour avec succès",
|
||||||
|
"entityMerged": "Fusion des nœuds réussie"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "Fusionner automatiquement en cas de nom dupliqué",
|
||||||
|
"mergeOptionDescription": "Si activé, renommer vers un nom existant fusionnera automatiquement ce nœud avec celui-ci au lieu d'échouer.",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "Nœud fusionné",
|
||||||
|
"description": "\"{{source}}\" a été fusionné dans \"{{target}}\".",
|
||||||
|
"refreshHint": "Actualisez le graphe pour charger la structure la plus récente.",
|
||||||
|
"keepCurrentStart": "Actualiser en conservant le nœud de départ actuel",
|
||||||
|
"useMergedStart": "Actualiser en utilisant le nœud fusionné",
|
||||||
|
"refreshing": "Actualisation du graphe..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "Nœud",
|
"title": "Nœud",
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "节点名称已存在",
|
"duplicateName": "节点名称已存在",
|
||||||
"updateFailed": "更新节点失败",
|
"updateFailed": "更新节点失败",
|
||||||
"tryAgainLater": "请稍后重试"
|
"tryAgainLater": "请稍后重试",
|
||||||
|
"updateSuccessButMergeFailed": "属性已更新,但合并失败:{{error}}",
|
||||||
|
"mergeFailed": "合并失败:{{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "节点更新成功",
|
"entityUpdated": "节点更新成功",
|
||||||
"relationUpdated": "关系更新成功"
|
"relationUpdated": "关系更新成功",
|
||||||
|
"entityMerged": "节点合并成功"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "重名时自动合并",
|
||||||
|
"mergeOptionDescription": "勾选后,重命名为已存在的名称会将当前节点自动合并过去,而不会报错。",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "节点已合并",
|
||||||
|
"description": "\"{{source}}\" 已合并到 \"{{target}}\"。",
|
||||||
|
"refreshHint": "请刷新图谱以获取最新结构。",
|
||||||
|
"keepCurrentStart": "刷新并保持当前起始节点",
|
||||||
|
"useMergedStart": "刷新并以合并后的节点为起始节点",
|
||||||
|
"refreshing": "正在刷新图谱..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "节点",
|
"title": "节点",
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "節點名稱已存在",
|
"duplicateName": "節點名稱已存在",
|
||||||
"updateFailed": "更新節點失敗",
|
"updateFailed": "更新節點失敗",
|
||||||
"tryAgainLater": "請稍後重試"
|
"tryAgainLater": "請稍後重試",
|
||||||
|
"updateSuccessButMergeFailed": "屬性已更新,但合併失敗:{{error}}",
|
||||||
|
"mergeFailed": "合併失敗:{{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "節點更新成功",
|
"entityUpdated": "節點更新成功",
|
||||||
"relationUpdated": "關係更新成功"
|
"relationUpdated": "關係更新成功",
|
||||||
|
"entityMerged": "節點合併成功"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "遇到重名時自動合併",
|
||||||
|
"mergeOptionDescription": "勾選後,重新命名為既有名稱時會自動將當前節點合併過去,不再報錯。",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "節點已合併",
|
||||||
|
"description": "\"{{source}}\" 已合併到 \"{{target}}\"。",
|
||||||
|
"refreshHint": "請重新整理圖譜以取得最新結構。",
|
||||||
|
"keepCurrentStart": "重新整理並保留目前的起始節點",
|
||||||
|
"useMergedStart": "重新整理並以合併後的節點為起始節點",
|
||||||
|
"refreshing": "正在重新整理圖譜..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "節點",
|
"title": "節點",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue