Merge pull request #2266 from danielaskdd/merge-entity

Refactor: Enhanced Entity Merging with Chunk Tracking
This commit is contained in:
Daniel.y 2025-10-28 02:40:58 +08:00 committed by GitHub
commit af6aff33d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1451 additions and 674 deletions

View file

@ -1 +1 @@
__api_version__ = "0245" __api_version__ = "0246"

View file

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

View file

@ -38,7 +38,7 @@ DEFAULT_ENTITY_TYPES = [
"NaturalObject", "NaturalObject",
] ]
# Separator for graph fields # Separator for: description, source_id and relation-key fields(Can not be changed after data inserted)
GRAPH_FIELD_SEP = "<SEP>" GRAPH_FIELD_SEP = "<SEP>"
# Query and retrieval configuration defaults # Query and retrieval configuration defaults

View file

@ -184,9 +184,17 @@ class NanoVectorDBStorage(BaseVectorStorage):
""" """
try: try:
client = await self._get_client() client = await self._get_client()
# Record count before deletion
before_count = len(client)
client.delete(ids) client.delete(ids)
# Calculate actual deleted count
after_count = len(client)
deleted_count = before_count - after_count
logger.debug( logger.debug(
f"[{self.workspace}] Successfully deleted {len(ids)} vectors from {self.namespace}" f"[{self.workspace}] Successfully deleted {deleted_count} vectors from {self.namespace}"
) )
except Exception as e: except Exception as e:
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(
@ -3750,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(

File diff suppressed because it is too large Load diff

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,8 +3,11 @@ 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 { SearchHistoryManager } from '@/utils/SearchHistoryManager'
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents' import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
import PropertyEditDialog from './PropertyEditDialog' import PropertyEditDialog from './PropertyEditDialog'
import MergeDialog from './MergeDialog'
/** /**
* Interface for the EditablePropertyRow component props * Interface for the EditablePropertyRow component props
@ -48,6 +51,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 +65,134 @@ 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)
// Remove old entity name from search history
SearchHistoryManager.removeLabel(entityId)
// Note: Search Label update is deferred until user clicks refresh button in merge dialog
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')
}
// Update search history: remove old name, add new name
if (name === 'entity_id') {
const currentLabel = useSettingsStore.getState().queryLabel
SearchHistoryManager.removeLabel(entityId)
SearchHistoryManager.addToHistory(finalValue)
// Trigger dropdown refresh to show updated search history
useSettingsStore.getState().triggerSearchLabelDropdownRefresh()
// If current queryLabel is the old entity name, update to new name
if (currentLabel === entityId) {
useSettingsStore.getState().setQueryLabel(finalValue)
}
}
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 +203,53 @@ 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()
const currentLabel = settingsState.queryLabel
// Clear graph state
graphState.clearSelection()
graphState.setGraphDataFetchAttempted(false)
graphState.setLastSuccessfulQueryLabel('')
if (useMergedStart && info?.targetEntity) {
// Use merged entity as new start point (might already be set in handleSave)
settingsState.setQueryLabel(info.targetEntity)
} else {
// Keep current start point - refresh by resetting and restoring label
// This handles the case where user wants to stay with current label
settingsState.setQueryLabel('')
setTimeout(() => {
settingsState.setQueryLabel(currentLabel)
}, 50)
}
// Force graph re-render and reset zoom/scale (same as refresh button behavior)
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,6 +266,19 @@ const EditablePropertyRow = ({
propertyName={name} propertyName={name}
initialValue={String(currentValue)} initialValue={String(currentValue)}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
errorMessage={errorMessage}
/>
<MergeDialog
mergeDialogOpen={mergeDialogOpen}
mergeDialogInfo={mergeDialogInfo}
onOpenChange={(open) => {
setMergeDialogOpen(open)
if (!open) {
setMergeDialogInfo(null)
}
}}
onRefresh={handleMergeRefresh}
/> />
</div> </div>
) )

View file

@ -17,6 +17,7 @@ import { getPopularLabels, searchLabels } from '@/api/lightrag'
const GraphLabels = () => { const GraphLabels = () => {
const { t } = useTranslation() const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel() const label = useSettingsStore.use.queryLabel()
const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [selectKey, setSelectKey] = useState(0) const [selectKey, setSelectKey] = useState(0)
@ -54,6 +55,18 @@ const GraphLabels = () => {
initializeHistory() initializeHistory()
}, []) }, [])
// Force AsyncSelect to re-render when label changes externally (e.g., from entity rename/merge)
useEffect(() => {
setSelectKey(prev => prev + 1)
}, [label])
// Force AsyncSelect to re-render when dropdown refresh is triggered (e.g., after entity rename)
useEffect(() => {
if (dropdownRefreshTrigger > 0) {
setSelectKey(prev => prev + 1)
}
}, [dropdownRefreshTrigger])
const fetchData = useCallback( const fetchData = useCallback(
async (query?: string): Promise<string[]> => { async (query?: string): Promise<string[]> => {
let results: string[] = []; let results: string[] = [];
@ -223,6 +236,9 @@ const GraphLabels = () => {
// Update the label to trigger data loading // Update the label to trigger data loading
useSettingsStore.getState().setQueryLabel(newLabel); useSettingsStore.getState().setQueryLabel(newLabel);
// Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel)
useGraphStore.getState().incrementGraphDataVersion();
}} }}
clearable={false} // Prevent clearing value on reselect clearable={false} // Prevent clearing value on reselect
debounceTime={500} debounceTime={500}

View file

@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
interface MergeDialogProps {
mergeDialogOpen: boolean
mergeDialogInfo: {
targetEntity: string
sourceEntity: string
} | null
onOpenChange: (open: boolean) => void
onRefresh: (useMergedStart: boolean) => void
}
/**
* MergeDialog component that appears after a successful entity merge
* Allows user to choose whether to use the merged entity or keep current start point
*/
const MergeDialog = ({
mergeDialogOpen,
mergeDialogInfo,
onOpenChange,
onRefresh
}: MergeDialogProps) => {
const { t } = useTranslation()
const currentQueryLabel = useSettingsStore.use.queryLabel()
return (
<Dialog open={mergeDialogOpen} onOpenChange={onOpenChange}>
<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">
{currentQueryLabel !== mergeDialogInfo?.sourceEntity && (
<Button
type="button"
variant="outline"
onClick={() => onRefresh(false)}
>
{t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}
</Button>
)}
<Button type="button" onClick={() => onRefresh(true)}>
{t('graphPanel.propertiesView.mergeDialog.useMergedStart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default MergeDialog

View file

@ -225,8 +225,8 @@ const PropertyRow = ({
formattedTooltip += `\n(Truncated: ${truncate})` formattedTooltip += `\n(Truncated: ${truncate})`
} }
// Use EditablePropertyRow for editable fields (description, entity_id and keywords) // Use EditablePropertyRow for editable fields (description, entity_id and entity_type)
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'keywords')) { if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type' || name === 'keywords')) {
return ( return (
<EditablePropertyRow <EditablePropertyRow
name={name} name={name}
@ -325,7 +325,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
nodeId={String(node.id)} nodeId={String(node.id)}
entityId={node.properties['entity_id']} entityId={node.properties['entity_id']}
entityType="node" entityType="node"
isEditable={name === 'description' || name === 'entity_id'} isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}
truncate={node.properties['truncate']} truncate={node.properties['truncate']}
/> />
) )

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

View file

@ -78,6 +78,10 @@ interface SettingsState {
currentTab: Tab currentTab: Tab
setCurrentTab: (tab: Tab) => void setCurrentTab: (tab: Tab) => void
// Search label dropdown refresh trigger (non-persistent, runtime only)
searchLabelDropdownRefreshTrigger: number
triggerSearchLabelDropdownRefresh: () => void
} }
const useSettingsStoreBase = create<SettingsState>()( const useSettingsStoreBase = create<SettingsState>()(
@ -229,7 +233,14 @@ const useSettingsStoreBase = create<SettingsState>()(
}) })
}, },
setUserPromptHistory: (history: string[]) => set({ userPromptHistory: history }) setUserPromptHistory: (history: string[]) => set({ userPromptHistory: history }),
// Search label dropdown refresh trigger (not persisted)
searchLabelDropdownRefreshTrigger: 0,
triggerSearchLabelDropdownRefresh: () =>
set((state) => ({
searchLabelDropdownRefreshTrigger: state.searchLabelDropdownRefreshTrigger + 1
}))
}), }),
{ {
name: 'settings-storage', name: 'settings-storage',