feat: Improve entity merge and edit UX

- **API:** The `graph/entity/edit` endpoint now returns a detailed `operation_summary` for better client-side handling of update, rename, and merge outcomes.
- **Web UI:** Added an "auto-merge on rename" option. The UI now gracefully handles merge success, partial failures (update OK, merge fail), and other errors with specific user feedback.
This commit is contained in:
yangdx 2025-10-27 23:42:08 +08:00
parent 97034f06e3
commit 5155edd8d2
11 changed files with 538 additions and 72 deletions

View file

@ -234,7 +234,49 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
causes name conflict (default: False) causes name conflict (default: False)
Returns: 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: Behavior when renaming to an existing entity:
- If allow_merge=False: Raises ValueError with 400 status (default behavior) - 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 "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): Example Request (rename with auto-merge):
POST /graph/entity/edit POST /graph/entity/edit
{ {
@ -261,6 +319,38 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
"allow_rename": true, "allow_rename": true,
"allow_merge": 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(
@ -269,10 +359,41 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
allow_rename=request.allow_rename, allow_rename=request.allow_rename,
allow_merge=request.allow_merge, 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(

View file

@ -3577,7 +3577,11 @@ 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.
@ -3588,6 +3592,7 @@ class LightRAG:
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
@ -3601,16 +3606,21 @@ class LightRAG:
entity_name, entity_name,
updated_data, updated_data,
allow_rename, allow_rename,
allow_merge,
self.entity_chunks, self.entity_chunks,
self.relation_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(

View file

@ -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

View file

@ -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
@ -719,17 +734,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
} }

View file

@ -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>
) )
} }

View file

@ -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"

View file

@ -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": "عقدة",

View file

@ -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",

View file

@ -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",

View file

@ -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": "节点",

View file

@ -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": "節點",