Merge pull request #2266 from danielaskdd/merge-entity
Refactor: Enhanced Entity Merging with Chunk Tracking
This commit is contained in:
commit
af6aff33d2
18 changed files with 1451 additions and 674 deletions
|
|
@ -1 +1 @@
|
|||
__api_version__ = "0245"
|
||||
__api_version__ = "0246"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class EntityUpdateRequest(BaseModel):
|
|||
entity_name: str
|
||||
updated_data: Dict[str, Any]
|
||||
allow_rename: bool = False
|
||||
allow_merge: bool = False
|
||||
|
||||
|
||||
class RelationUpdateRequest(BaseModel):
|
||||
|
|
@ -221,22 +222,178 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
|
|||
"""
|
||||
Update an entity's properties in the knowledge graph
|
||||
|
||||
This endpoint allows updating entity properties, including renaming entities.
|
||||
When renaming to an existing entity name, the behavior depends on allow_merge:
|
||||
|
||||
Args:
|
||||
request (EntityUpdateRequest): Request containing entity name, updated data, and rename flag
|
||||
request (EntityUpdateRequest): Request containing:
|
||||
- entity_name (str): Name of the entity to update
|
||||
- updated_data (Dict[str, Any]): Dictionary of properties to update
|
||||
- allow_rename (bool): Whether to allow entity renaming (default: False)
|
||||
- allow_merge (bool): Whether to merge into existing entity when renaming
|
||||
causes name conflict (default: False)
|
||||
|
||||
Returns:
|
||||
Dict: Updated entity information
|
||||
Dict with the following structure:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity updated successfully" | "Entity merged successfully into 'target_name'",
|
||||
"data": {
|
||||
"entity_name": str, # Final entity name
|
||||
"description": str, # Entity description
|
||||
"entity_type": str, # Entity type
|
||||
"source_id": str, # Source chunk IDs
|
||||
... # Other entity properties
|
||||
},
|
||||
"operation_summary": {
|
||||
"merged": bool, # Whether entity was merged into another
|
||||
"merge_status": str, # "success" | "failed" | "not_attempted"
|
||||
"merge_error": str | None, # Error message if merge failed
|
||||
"operation_status": str, # "success" | "partial_success" | "failure"
|
||||
"target_entity": str | None, # Target entity name if renaming/merging
|
||||
"final_entity": str, # Final entity name after operation
|
||||
"renamed": bool # Whether entity was renamed
|
||||
}
|
||||
}
|
||||
|
||||
operation_status values explained:
|
||||
- "success": All operations completed successfully
|
||||
* For simple updates: entity properties updated
|
||||
* For renames: entity renamed successfully
|
||||
* For merges: non-name updates applied AND merge completed
|
||||
|
||||
- "partial_success": Update succeeded but merge failed
|
||||
* Non-name property updates were applied successfully
|
||||
* Merge operation failed (entity not merged)
|
||||
* Original entity still exists with updated properties
|
||||
* Use merge_error for failure details
|
||||
|
||||
- "failure": Operation failed completely
|
||||
* If merge_status == "failed": Merge attempted but both update and merge failed
|
||||
* If merge_status == "not_attempted": Regular update failed
|
||||
* No changes were applied to the entity
|
||||
|
||||
merge_status values explained:
|
||||
- "success": Entity successfully merged into target entity
|
||||
- "failed": Merge operation was attempted but failed
|
||||
- "not_attempted": No merge was attempted (normal update/rename)
|
||||
|
||||
Behavior when renaming to an existing entity:
|
||||
- If allow_merge=False: Raises ValueError with 400 status (default behavior)
|
||||
- If allow_merge=True: Automatically merges the source entity into the existing target entity,
|
||||
preserving all relationships and applying non-name updates first
|
||||
|
||||
Example Request (simple update):
|
||||
POST /graph/entity/edit
|
||||
{
|
||||
"entity_name": "Tesla",
|
||||
"updated_data": {"description": "Updated description"},
|
||||
"allow_rename": false,
|
||||
"allow_merge": false
|
||||
}
|
||||
|
||||
Example Response (simple update success):
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity updated successfully",
|
||||
"data": { ... },
|
||||
"operation_summary": {
|
||||
"merged": false,
|
||||
"merge_status": "not_attempted",
|
||||
"merge_error": null,
|
||||
"operation_status": "success",
|
||||
"target_entity": null,
|
||||
"final_entity": "Tesla",
|
||||
"renamed": false
|
||||
}
|
||||
}
|
||||
|
||||
Example Request (rename with auto-merge):
|
||||
POST /graph/entity/edit
|
||||
{
|
||||
"entity_name": "Elon Msk",
|
||||
"updated_data": {
|
||||
"entity_name": "Elon Musk",
|
||||
"description": "Corrected description"
|
||||
},
|
||||
"allow_rename": true,
|
||||
"allow_merge": true
|
||||
}
|
||||
|
||||
Example Response (merge success):
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity merged successfully into 'Elon Musk'",
|
||||
"data": { ... },
|
||||
"operation_summary": {
|
||||
"merged": true,
|
||||
"merge_status": "success",
|
||||
"merge_error": null,
|
||||
"operation_status": "success",
|
||||
"target_entity": "Elon Musk",
|
||||
"final_entity": "Elon Musk",
|
||||
"renamed": true
|
||||
}
|
||||
}
|
||||
|
||||
Example Response (partial success - update succeeded but merge failed):
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity updated successfully",
|
||||
"data": { ... }, # Data reflects updated "Elon Msk" entity
|
||||
"operation_summary": {
|
||||
"merged": false,
|
||||
"merge_status": "failed",
|
||||
"merge_error": "Target entity locked by another operation",
|
||||
"operation_status": "partial_success",
|
||||
"target_entity": "Elon Musk",
|
||||
"final_entity": "Elon Msk", # Original entity still exists
|
||||
"renamed": true
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
result = await rag.aedit_entity(
|
||||
entity_name=request.entity_name,
|
||||
updated_data=request.updated_data,
|
||||
allow_rename=request.allow_rename,
|
||||
allow_merge=request.allow_merge,
|
||||
)
|
||||
|
||||
# Extract operation_summary from result, with fallback for backward compatibility
|
||||
operation_summary = result.get(
|
||||
"operation_summary",
|
||||
{
|
||||
"merged": False,
|
||||
"merge_status": "not_attempted",
|
||||
"merge_error": None,
|
||||
"operation_status": "success",
|
||||
"target_entity": None,
|
||||
"final_entity": request.updated_data.get(
|
||||
"entity_name", request.entity_name
|
||||
),
|
||||
"renamed": request.updated_data.get(
|
||||
"entity_name", request.entity_name
|
||||
)
|
||||
!= request.entity_name,
|
||||
},
|
||||
)
|
||||
|
||||
# Separate entity data from operation_summary for clean response
|
||||
entity_data = dict(result)
|
||||
entity_data.pop("operation_summary", None)
|
||||
|
||||
# Generate appropriate response message based on merge status
|
||||
response_message = (
|
||||
f"Entity merged successfully into '{operation_summary['final_entity']}'"
|
||||
if operation_summary.get("merged")
|
||||
else "Entity updated successfully"
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Entity updated successfully",
|
||||
"data": result,
|
||||
"message": response_message,
|
||||
"data": entity_data,
|
||||
"operation_summary": operation_summary,
|
||||
}
|
||||
except ValueError as ve:
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ DEFAULT_ENTITY_TYPES = [
|
|||
"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>"
|
||||
|
||||
# Query and retrieval configuration defaults
|
||||
|
|
|
|||
|
|
@ -184,9 +184,17 @@ class NanoVectorDBStorage(BaseVectorStorage):
|
|||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
# Record count before deletion
|
||||
before_count = len(client)
|
||||
|
||||
client.delete(ids)
|
||||
|
||||
# Calculate actual deleted count
|
||||
after_count = len(client)
|
||||
deleted_count = before_count - after_count
|
||||
|
||||
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:
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -3750,6 +3760,8 @@ class LightRAG:
|
|||
target_entity,
|
||||
merge_strategy,
|
||||
target_entity_data,
|
||||
self.entity_chunks,
|
||||
self.relation_chunks,
|
||||
)
|
||||
|
||||
def merge_entities(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -143,6 +143,21 @@ export type QueryResponse = {
|
|||
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 = {
|
||||
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<string, any>,
|
||||
allowRename: boolean = false
|
||||
): Promise<DocActionResponse> => {
|
||||
allowRename: boolean = false,
|
||||
allowMerge: boolean = false
|
||||
): Promise<EntityUpdateResponse> => {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ 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 { SearchHistoryManager } from '@/utils/SearchHistoryManager'
|
||||
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
|
||||
import PropertyEditDialog from './PropertyEditDialog'
|
||||
import MergeDialog from './MergeDialog'
|
||||
|
||||
/**
|
||||
* Interface for the EditablePropertyRow component props
|
||||
|
|
@ -48,6 +51,12 @@ const EditablePropertyRow = ({
|
|||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
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(() => {
|
||||
setCurrentValue(initialValue)
|
||||
|
|
@ -56,42 +65,134 @@ 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)
|
||||
|
||||
// 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) {
|
||||
const updatedData = { [name]: value }
|
||||
await updateRelation(sourceId, targetId, updatedData)
|
||||
|
|
@ -102,19 +203,53 @@ 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()
|
||||
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 (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<PropertyName name={name} />
|
||||
|
|
@ -131,6 +266,19 @@ const EditablePropertyRow = ({
|
|||
propertyName={name}
|
||||
initialValue={String(currentValue)}
|
||||
isSubmitting={isSubmitting}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
|
||||
<MergeDialog
|
||||
mergeDialogOpen={mergeDialogOpen}
|
||||
mergeDialogInfo={mergeDialogInfo}
|
||||
onOpenChange={(open) => {
|
||||
setMergeDialogOpen(open)
|
||||
if (!open) {
|
||||
setMergeDialogInfo(null)
|
||||
}
|
||||
}}
|
||||
onRefresh={handleMergeRefresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getPopularLabels, searchLabels } from '@/api/lightrag'
|
|||
const GraphLabels = () => {
|
||||
const { t } = useTranslation()
|
||||
const label = useSettingsStore.use.queryLabel()
|
||||
const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [selectKey, setSelectKey] = useState(0)
|
||||
|
|
@ -54,6 +55,18 @@ const GraphLabels = () => {
|
|||
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(
|
||||
async (query?: string): Promise<string[]> => {
|
||||
let results: string[] = [];
|
||||
|
|
@ -223,6 +236,9 @@ const GraphLabels = () => {
|
|||
|
||||
// Update the label to trigger data loading
|
||||
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
|
||||
debounceTime={500}
|
||||
|
|
|
|||
70
lightrag_webui/src/components/graph/MergeDialog.tsx
Normal file
70
lightrag_webui/src/components/graph/MergeDialog.tsx
Normal 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
|
||||
|
|
@ -225,8 +225,8 @@ const PropertyRow = ({
|
|||
formattedTooltip += `\n(Truncated: ${truncate})`
|
||||
}
|
||||
|
||||
// Use EditablePropertyRow for editable fields (description, entity_id and keywords)
|
||||
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'keywords')) {
|
||||
// Use EditablePropertyRow for editable fields (description, entity_id and entity_type)
|
||||
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type' || name === 'keywords')) {
|
||||
return (
|
||||
<EditablePropertyRow
|
||||
name={name}
|
||||
|
|
@ -325,7 +325,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
|||
nodeId={String(node.id)}
|
||||
entityId={node.properties['entity_id']}
|
||||
entityType="node"
|
||||
isEditable={name === 'description' || name === 'entity_id'}
|
||||
isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}
|
||||
truncate={node.properties['truncate']}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 = ({
|
|||
</DialogHeader>
|
||||
|
||||
{/* Display error message if save fails */}
|
||||
{error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm mt-2">
|
||||
{error}
|
||||
{errorMessage && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -146,6 +139,25 @@ const PropertyEditDialog = ({
|
|||
})()}
|
||||
</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>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -305,11 +305,24 @@
|
|||
"errors": {
|
||||
"duplicateName": "اسم العقدة موجود بالفعل",
|
||||
"updateFailed": "فشل تحديث العقدة",
|
||||
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا"
|
||||
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"updateSuccessButMergeFailed": "تم تحديث الخصائص، لكن الدمج فشل: {{error}}",
|
||||
"mergeFailed": "فشل الدمج: {{error}}"
|
||||
},
|
||||
"success": {
|
||||
"entityUpdated": "تم تحديث العقدة بنجاح",
|
||||
"relationUpdated": "تم تحديث العلاقة بنجاح"
|
||||
"relationUpdated": "تم تحديث العلاقة بنجاح",
|
||||
"entityMerged": "تم دمج العقد بنجاح"
|
||||
},
|
||||
"mergeOptionLabel": "دمج تلقائي عند العثور على اسم مكرر",
|
||||
"mergeOptionDescription": "عند التفعيل، سيتم دمج هذه العقدة تلقائيًا في العقدة الموجودة بدلاً من ظهور خطأ عند إعادة التسمية بنفس الاسم.",
|
||||
"mergeDialog": {
|
||||
"title": "تم دمج العقدة",
|
||||
"description": "\"{{source}}\" تم دمجها في \"{{target}}\".",
|
||||
"refreshHint": "يجب تحديث الرسم البياني لتحميل البنية الأحدث.",
|
||||
"keepCurrentStart": "تحديث مع الحفاظ على عقدة البدء الحالية",
|
||||
"useMergedStart": "تحديث واستخدام العقدة المدمجة كنقطة بدء",
|
||||
"refreshing": "جارٍ تحديث الرسم البياني..."
|
||||
},
|
||||
"node": {
|
||||
"title": "عقدة",
|
||||
|
|
|
|||
|
|
@ -305,11 +305,24 @@
|
|||
"errors": {
|
||||
"duplicateName": "Node name already exists",
|
||||
"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": {
|
||||
"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": {
|
||||
"title": "Node",
|
||||
|
|
|
|||
|
|
@ -305,11 +305,24 @@
|
|||
"errors": {
|
||||
"duplicateName": "Le nom du nœud existe déjà",
|
||||
"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": {
|
||||
"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": {
|
||||
"title": "Nœud",
|
||||
|
|
|
|||
|
|
@ -305,11 +305,24 @@
|
|||
"errors": {
|
||||
"duplicateName": "节点名称已存在",
|
||||
"updateFailed": "更新节点失败",
|
||||
"tryAgainLater": "请稍后重试"
|
||||
"tryAgainLater": "请稍后重试",
|
||||
"updateSuccessButMergeFailed": "属性已更新,但合并失败:{{error}}",
|
||||
"mergeFailed": "合并失败:{{error}}"
|
||||
},
|
||||
"success": {
|
||||
"entityUpdated": "节点更新成功",
|
||||
"relationUpdated": "关系更新成功"
|
||||
"relationUpdated": "关系更新成功",
|
||||
"entityMerged": "节点合并成功"
|
||||
},
|
||||
"mergeOptionLabel": "重名时自动合并",
|
||||
"mergeOptionDescription": "勾选后,重命名为已存在的名称会将当前节点自动合并过去,而不会报错。",
|
||||
"mergeDialog": {
|
||||
"title": "节点已合并",
|
||||
"description": "\"{{source}}\" 已合并到 \"{{target}}\"。",
|
||||
"refreshHint": "请刷新图谱以获取最新结构。",
|
||||
"keepCurrentStart": "刷新并保持当前起始节点",
|
||||
"useMergedStart": "刷新并以合并后的节点为起始节点",
|
||||
"refreshing": "正在刷新图谱..."
|
||||
},
|
||||
"node": {
|
||||
"title": "节点",
|
||||
|
|
|
|||
|
|
@ -305,11 +305,24 @@
|
|||
"errors": {
|
||||
"duplicateName": "節點名稱已存在",
|
||||
"updateFailed": "更新節點失敗",
|
||||
"tryAgainLater": "請稍後重試"
|
||||
"tryAgainLater": "請稍後重試",
|
||||
"updateSuccessButMergeFailed": "屬性已更新,但合併失敗:{{error}}",
|
||||
"mergeFailed": "合併失敗:{{error}}"
|
||||
},
|
||||
"success": {
|
||||
"entityUpdated": "節點更新成功",
|
||||
"relationUpdated": "關係更新成功"
|
||||
"relationUpdated": "關係更新成功",
|
||||
"entityMerged": "節點合併成功"
|
||||
},
|
||||
"mergeOptionLabel": "遇到重名時自動合併",
|
||||
"mergeOptionDescription": "勾選後,重新命名為既有名稱時會自動將當前節點合併過去,不再報錯。",
|
||||
"mergeDialog": {
|
||||
"title": "節點已合併",
|
||||
"description": "\"{{source}}\" 已合併到 \"{{target}}\"。",
|
||||
"refreshHint": "請重新整理圖譜以取得最新結構。",
|
||||
"keepCurrentStart": "重新整理並保留目前的起始節點",
|
||||
"useMergedStart": "重新整理並以合併後的節點為起始節點",
|
||||
"refreshing": "正在重新整理圖譜..."
|
||||
},
|
||||
"node": {
|
||||
"title": "節點",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ interface SettingsState {
|
|||
|
||||
currentTab: Tab
|
||||
setCurrentTab: (tab: Tab) => void
|
||||
|
||||
// Search label dropdown refresh trigger (non-persistent, runtime only)
|
||||
searchLabelDropdownRefreshTrigger: number
|
||||
triggerSearchLabelDropdownRefresh: () => void
|
||||
}
|
||||
|
||||
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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue