From 2a451c4e226f0b7d55e849fa570504fcd666676c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20MANSUY?= Date: Thu, 4 Dec 2025 19:18:39 +0800 Subject: [PATCH] cherry-pick 5155edd8 --- lightrag/api/routers/graph_routes.py | 167 ++++++++++++++- lightrag/lightrag.py | 191 ++++++++++++++---- lightrag/utils_graph.py | 132 ++++++++++-- lightrag_webui/src/api/lightrag.ts | 24 ++- .../components/graph/EditablePropertyRow.tsx | 176 ++++++++++++++-- .../components/graph/PropertyEditDialog.tsx | 50 +++-- lightrag_webui/src/locales/ar.json | 17 +- lightrag_webui/src/locales/en.json | 17 +- lightrag_webui/src/locales/fr.json | 17 +- lightrag_webui/src/locales/zh.json | 17 +- lightrag_webui/src/locales/zh_TW.json | 17 +- 11 files changed, 718 insertions(+), 107 deletions(-) diff --git a/lightrag/api/routers/graph_routes.py b/lightrag/api/routers/graph_routes.py index f4c29fc2..e892ff01 100644 --- a/lightrag/api/routers/graph_routes.py +++ b/lightrag/api/routers/graph_routes.py @@ -17,6 +17,7 @@ class EntityUpdateRequest(BaseModel): entity_name: str updated_data: Dict[str, Any] allow_rename: bool = False + allow_merge: bool = False 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 + This endpoint allows updating entity properties, including renaming entities. + When renaming to an existing entity name, the behavior depends on allow_merge: + 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: - 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: result = await rag.aedit_entity( entity_name=request.entity_name, updated_data=request.updated_data, 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 { "status": "success", - "message": "Entity updated successfully", - "data": result, + "message": response_message, + "data": entity_data, + "operation_summary": operation_summary, } except ValueError as ve: 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. 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 generates vector embeddings for the relationship to enable semantic search and graph traversal. diff --git a/lightrag/lightrag.py b/lightrag/lightrag.py index 1c6a7c61..45f7afd5 100644 --- a/lightrag/lightrag.py +++ b/lightrag/lightrag.py @@ -22,6 +22,7 @@ from typing import ( Dict, ) from lightrag.prompt import PROMPTS +from lightrag.exceptions import PipelineCancelledException from lightrag.constants import ( DEFAULT_MAX_GLEANING, DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, @@ -1603,6 +1604,7 @@ class LightRAG: "batchs": 0, # Total number of files to be processed "cur_batch": 0, # Number of files already processed "request_pending": False, # Clear any previous request + "cancellation_requested": False, # Initialize cancellation flag "latest_message": "", } ) @@ -1619,6 +1621,22 @@ class LightRAG: try: # Process documents until no more documents or requests 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: log_message = "All enqueued documents have been processed" logger.info(log_message) @@ -1681,14 +1699,25 @@ class LightRAG: semaphore: asyncio.Semaphore, ) -> None: """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 + processing_start_time = int(time.time()) + first_stage_tasks = [] + entity_relation_task = None + async with semaphore: nonlocal processed_count - current_file_number = 0 # Initialize to prevent UnboundLocalError in error handling first_stage_tasks = [] entity_relation_task = None 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 file_path = getattr( status_doc, "file_path", "unknown_source" @@ -1751,6 +1780,11 @@ class LightRAG: # Record processing start 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 # Stage 1: Process text chunks and docs (parallel execution) doc_status_task = asyncio.create_task( @@ -1805,16 +1839,29 @@ class LightRAG: file_extraction_stage_ok = True except Exception as e: - # Log error and update pipeline status - 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) + # Check if this is a user cancellation + if isinstance(e, PipelineCancelledException): + # User cancellation - log brief message only, no traceback + error_msg = f"User cancelled {current_file_number}/{total_files}: {file_path}" + logger.warning(error_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = 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 all_tasks = first_stage_tasks + ( @@ -1824,9 +1871,14 @@ class LightRAG: if task and not task.done(): task.cancel() - # Persistent llm cache + # Persistent llm cache with error handling 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 processing_end_time = int(time.time()) @@ -1856,6 +1908,15 @@ class LightRAG: # Concurrency is controlled by keyed lock for individual entities and relationships if file_extraction_stage_ok: 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 chunk_results = await entity_relation_task await merge_nodes_and_edges( @@ -1914,22 +1975,38 @@ class LightRAG: ) except Exception as e: - # Log error and update pipeline status - 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 - ) + # Check if this is a user cancellation + if isinstance(e, PipelineCancelledException): + # User cancellation - log brief message only, no traceback + error_msg = f"User cancelled during merge {current_file_number}/{total_files}: {file_path}" + logger.warning(error_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = 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: - 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 processing_end_time = int(time.time()) @@ -1970,7 +2047,19 @@ class LightRAG: ) # 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) has_pending_request = False @@ -2001,11 +2090,14 @@ class LightRAG: to_process_docs.update(pending_docs) finally: - log_message = "Enqueued document processing pipeline stoped" + log_message = "Enqueued document processing pipeline stopped" 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: pipeline_status["busy"] = False + pipeline_status["cancellation_requested"] = ( + False # Always reset cancellation flag + ) pipeline_status["latest_message"] = log_message pipeline_status["history_messages"].append(log_message) @@ -3210,6 +3302,10 @@ class LightRAG: 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: log_message = f"Successfully deleted {len(entities_to_delete)} entities" logger.info(log_message) @@ -3239,6 +3335,14 @@ class LightRAG: 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: log_message = f"Successfully deleted {len(relationships_to_delete)} relations" logger.info(log_message) @@ -3302,9 +3406,7 @@ class LightRAG: pipeline_status["history_messages"].append(cache_log_message) log_message = cache_log_message except Exception as cache_delete_error: - log_message = ( - f"Failed to delete LLM cache for document {doc_id}: {cache_delete_error}" - ) + log_message = f"Failed to delete LLM cache for document {doc_id}: {cache_delete_error}" logger.error(log_message) logger.error(traceback.format_exc()) async with pipeline_status_lock: @@ -3475,16 +3577,22 @@ class LightRAG: ) 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]: """Asynchronously edit entity information. 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: entity_name: Name of the entity to edit 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_merge: Whether to merge into an existing entity when renaming to an existing name Returns: Dictionary containing updated entity information @@ -3498,14 +3606,21 @@ class LightRAG: entity_name, updated_data, allow_rename, + allow_merge, + self.entity_chunks, + self.relation_chunks, ) 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]: loop = always_get_an_event_loop() 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( @@ -3514,6 +3629,7 @@ class LightRAG: """Asynchronously edit relation information. 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: source_entity: Name of the source entity @@ -3532,6 +3648,7 @@ class LightRAG: source_entity, target_entity, updated_data, + self.relation_chunks, ) def edit_relation( @@ -3643,6 +3760,8 @@ class LightRAG: target_entity, merge_strategy, target_entity_data, + self.entity_chunks, + self.relation_chunks, ) def merge_entities( diff --git a/lightrag/utils_graph.py b/lightrag/utils_graph.py index c18c17c0..0a4dae92 100644 --- a/lightrag/utils_graph.py +++ b/lightrag/utils_graph.py @@ -540,7 +540,33 @@ async def aedit_entity( relation_chunks_storage: Optional KV storage for tracking chunks that reference relations 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) is_renaming = new_entity_name != entity_name @@ -549,6 +575,16 @@ async def aedit_entity( workspace = entities_vdb.global_config.get("workspace", "") 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( 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}`" ) + # Track whether non-name updates were applied + non_name_updates_applied = False non_name_updates = { key: value for key, value in updated_data.items() if key != "entity_name" } + + # Apply non-name updates first if non_name_updates: - logger.info( - "Entity Edit: applying non-name updates before merge" - ) - await _edit_entity_impl( + try: + logger.info( + "Entity Edit: applying non-name updates before merge" + ) + 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, entities_vdb, relationships_vdb, - entity_name, - non_name_updates, + [entity_name], + new_entity_name, + merge_strategy=None, + target_entity_data=None, entity_chunks_storage=entity_chunks_storage, relation_chunks_storage=relation_chunks_storage, ) - return await _merge_entities_impl( - chunk_entity_relation_graph, - entities_vdb, - relationships_vdb, - [entity_name], - new_entity_name, - merge_strategy=None, - target_entity_data=None, - entity_chunks_storage=entity_chunks_storage, - relation_chunks_storage=relation_chunks_storage, - ) + # Merge succeeded + operation_summary.update( + { + "merged": True, + "merge_status": "success", + "merge_error": None, + "operation_status": "success", + "target_entity": new_entity_name, + "final_entity": new_entity_name, + } + ) + 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, entities_vdb, relationships_vdb, @@ -612,6 +703,9 @@ async def aedit_entity( entity_chunks_storage=entity_chunks_storage, relation_chunks_storage=relation_chunks_storage, ) + operation_summary["operation_status"] = "success" + return {**edit_result, "operation_summary": operation_summary} + except Exception as e: logger.error(f"Error while editing entity '{entity_name}': {e}") raise diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index 5b273595..c724ae68 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -143,6 +143,21 @@ export type QueryResponse = { response: string } +export type EntityUpdateResponse = { + status: string + message: string + data: Record + 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 = { status: 'success' | 'partial_success' | 'failure' | 'duplicated' 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 updatedData Dictionary containing updated attributes * @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 */ export const updateEntity = async ( entityName: string, updatedData: Record, - allowRename: boolean = false -): Promise => { + allowRename: boolean = false, + allowMerge: boolean = false +): Promise => { const response = await axiosInstance.post('/graph/entity/edit', { entity_name: entityName, updated_data: updatedData, - allow_rename: allowRename + allow_rename: allowRename, + allow_merge: allowMerge }) return response.data } diff --git a/lightrag_webui/src/components/graph/EditablePropertyRow.tsx b/lightrag_webui/src/components/graph/EditablePropertyRow.tsx index 40db2bdf..8f6639c1 100644 --- a/lightrag_webui/src/components/graph/EditablePropertyRow.tsx +++ b/lightrag_webui/src/components/graph/EditablePropertyRow.tsx @@ -3,6 +3,16 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag' 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 PropertyEditDialog from './PropertyEditDialog' @@ -48,6 +58,12 @@ const EditablePropertyRow = ({ const [isEditing, setIsEditing] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [currentValue, setCurrentValue] = useState(initialValue) + const [errorMessage, setErrorMessage] = useState(null) + const [mergeDialogOpen, setMergeDialogOpen] = useState(false) + const [mergeDialogInfo, setMergeDialogInfo] = useState<{ + targetEntity: string + sourceEntity: string + } | null>(null) useEffect(() => { setCurrentValue(initialValue) @@ -56,42 +72,111 @@ const EditablePropertyRow = ({ const handleEditClick = () => { if (isEditable && !isEditing) { setIsEditing(true) + setErrorMessage(null) } } const handleCancel = () => { setIsEditing(false) + setErrorMessage(null) } - const handleSave = async (value: string) => { + const handleSave = async (value: string, options?: { allowMerge?: boolean }) => { if (isSubmitting || value === String(currentValue)) { setIsEditing(false) + setErrorMessage(null) return } setIsSubmitting(true) + setErrorMessage(null) try { if (entityType === 'node' && entityId && nodeId) { let updatedData = { [name]: value } + const allowMerge = options?.allowMerge ?? false if (name === 'entity_id') { - const exists = await checkEntityNameExists(value) - if (exists) { - toast.error(t('graphPanel.propertiesView.errors.duplicateName')) - return + if (!allowMerge) { + const exists = await checkEntityNameExists(value) + if (exists) { + const errorMsg = t('graphPanel.propertiesView.errors.duplicateName') + setErrorMessage(errorMsg) + toast.error(errorMsg) + return + } } updatedData = { 'entity_name': value } } - await updateEntity(entityId, updatedData, true) - try { - await useGraphStore.getState().updateNodeAndSelect(nodeId, entityId, name, value) - } catch (error) { - console.error('Error updating node in graph:', error) - throw new Error('Failed to update node in graph') + const response = await updateEntity(entityId, updatedData, true, allowMerge) + const operationSummary = response.operation_summary + const operationStatus = operationSummary?.operation_status || 'complete_success' + const finalValue = operationSummary?.final_entity ?? value + + // 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) { const updatedData = { [name]: value } await updateRelation(sourceId, targetId, updatedData) @@ -102,19 +187,42 @@ const EditablePropertyRow = ({ throw new Error('Failed to update edge in graph') } toast.success(t('graphPanel.propertiesView.success.relationUpdated')) + setCurrentValue(value) + onValueChange?.(value) } setIsEditing(false) - setCurrentValue(value) - onValueChange?.(value) } catch (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 { 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 (
@@ -131,7 +239,45 @@ const EditablePropertyRow = ({ propertyName={name} initialValue={String(currentValue)} isSubmitting={isSubmitting} + errorMessage={errorMessage} /> + + { + setMergeDialogOpen(open) + if (!open) { + setMergeDialogInfo(null) + } + }} + > + + + {t('graphPanel.propertiesView.mergeDialog.title')} + + {t('graphPanel.propertiesView.mergeDialog.description', { + source: mergeDialogInfo?.sourceEntity ?? '', + target: mergeDialogInfo?.targetEntity ?? '', + })} + + +

+ {t('graphPanel.propertiesView.mergeDialog.refreshHint')} +

+ + + + +
+
) } diff --git a/lightrag_webui/src/components/graph/PropertyEditDialog.tsx b/lightrag_webui/src/components/graph/PropertyEditDialog.tsx index 65c71c4e..001861a6 100644 --- a/lightrag_webui/src/components/graph/PropertyEditDialog.tsx +++ b/lightrag_webui/src/components/graph/PropertyEditDialog.tsx @@ -9,14 +9,16 @@ import { DialogDescription } from '@/components/ui/Dialog' import Button from '@/components/ui/Button' +import Checkbox from '@/components/ui/Checkbox' interface PropertyEditDialogProps { isOpen: boolean onClose: () => void - onSave: (value: string) => void + onSave: (value: string, options?: { allowMerge?: boolean }) => void propertyName: string initialValue: string isSubmitting?: boolean + errorMessage?: string | null } /** @@ -29,17 +31,18 @@ const PropertyEditDialog = ({ onSave, propertyName, initialValue, - isSubmitting = false + isSubmitting = false, + errorMessage = null }: PropertyEditDialogProps) => { const { t } = useTranslation() const [value, setValue] = useState('') - // Add error state to display save failure messages - const [error, setError] = useState(null) + const [allowMerge, setAllowMerge] = useState(false) // Initialize value when dialog opens useEffect(() => { if (isOpen) { setValue(initialValue) + setAllowMerge(false) } }, [isOpen, initialValue]) @@ -86,18 +89,8 @@ const PropertyEditDialog = ({ const handleSave = async () => { if (value.trim() !== '') { - // Clear previous error messages - setError(null) - 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')) - } + const options = propertyName === 'entity_id' ? { allowMerge } : undefined + await onSave(value, options) } } @@ -116,9 +109,9 @@ const PropertyEditDialog = ({ {/* Display error message if save fails */} - {error && ( -
- {error} + {errorMessage && ( +
+ {errorMessage}
)} @@ -146,6 +139,25 @@ const PropertyEditDialog = ({ })()}
+ {propertyName === 'entity_id' && ( +
+ +
+ )} +