diff --git a/lightrag/api/routers/graph_routes.py b/lightrag/api/routers/graph_routes.py index db75b231..e892ff01 100644 --- a/lightrag/api/routers/graph_routes.py +++ b/lightrag/api/routers/graph_routes.py @@ -234,7 +234,49 @@ def create_graph_routes(rag, api_key: Optional[str] = None): causes name conflict (default: False) Returns: - Dict: Updated entity information with status + 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) @@ -250,6 +292,22 @@ def create_graph_routes(rag, api_key: Optional[str] = None): "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 { @@ -261,6 +319,38 @@ def create_graph_routes(rag, api_key: Optional[str] = None): "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( @@ -269,10 +359,41 @@ def create_graph_routes(rag, api_key: Optional[str] = None): 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( diff --git a/lightrag/lightrag.py b/lightrag/lightrag.py index bdc94b2c..45f7afd5 100644 --- a/lightrag/lightrag.py +++ b/lightrag/lightrag.py @@ -3577,7 +3577,11 @@ 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. @@ -3588,6 +3592,7 @@ class LightRAG: 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 @@ -3601,16 +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( 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 7a268642..7cf1aec6 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 @@ -719,17 +734,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' && ( +
+ +
+ )} +