From 7e3e6857638d701fbf01739edf2e39cc1c6a0f15 Mon Sep 17 00:00:00 2001 From: choizhang Date: Sat, 12 Apr 2025 00:48:19 +0800 Subject: [PATCH] feat(graph): Add editing function for entity and relationship attributes --- lightrag/api/routers/graph_routes.py | 62 ++++- lightrag_webui/src/api/lightrag.ts | 55 ++++ .../components/graph/EditablePropertyRow.tsx | 245 ++++++++++++++++++ .../src/components/graph/PropertiesView.tsx | 57 +++- lightrag_webui/src/lib/constants.ts | 2 +- lightrag_webui/src/stores/graph.ts | 8 + 6 files changed, 421 insertions(+), 8 deletions(-) create mode 100644 lightrag_webui/src/components/graph/EditablePropertyRow.tsx diff --git a/lightrag/api/routers/graph_routes.py b/lightrag/api/routers/graph_routes.py index 381df90b..97a397d4 100644 --- a/lightrag/api/routers/graph_routes.py +++ b/lightrag/api/routers/graph_routes.py @@ -2,14 +2,21 @@ This module contains all graph-related routes for the LightRAG API. """ -from typing import Optional -from fastapi import APIRouter, Depends, Query +from typing import Optional, Dict, Any +from fastapi import APIRouter, Depends, Query, HTTPException +from pydantic import BaseModel from ..utils_api import get_combined_auth_dependency router = APIRouter(tags=["graph"]) +class EntityUpdateRequest(BaseModel): + entity_name: str + updated_data: Dict[str, Any] + allow_rename: bool = False + + def create_graph_routes(rag, api_key: Optional[str] = None): combined_auth = get_combined_auth_dependency(api_key) @@ -49,4 +56,55 @@ def create_graph_routes(rag, api_key: Optional[str] = None): max_nodes=max_nodes, ) + @router.get("/graph/entity/exists", dependencies=[Depends(combined_auth)]) + async def check_entity_exists( + name: str = Query(..., description="Entity name to check"), + ): + """ + Check if an entity with the given name exists in the knowledge graph + + Args: + name (str): Name of the entity to check + + Returns: + Dict[str, bool]: Dictionary with 'exists' key indicating if entity exists + """ + try: + exists = await rag.chunk_entity_relation_graph.has_node(name) + return {"exists": exists} + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error checking entity existence: {str(e)}" + ) + + @router.post("/graph/entity/edit", dependencies=[Depends(combined_auth)]) + async def update_entity(request: EntityUpdateRequest): + """ + Update an entity's properties in the knowledge graph + + Args: + request (EntityUpdateRequest): Request containing entity name, updated data, and rename flag + + Returns: + Dict: Updated entity information + """ + try: + print(request.entity_name, request.updated_data, request.allow_rename) + result = await rag.aedit_entity( + entity_name=request.entity_name, + updated_data=request.updated_data, + allow_rename=request.allow_rename, + ) + return { + "status": "success", + "message": "Entity updated successfully", + "data": result, + } + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error updating entity: {str(e)}" + ) + return router diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index fbe00d23..63f4e2f6 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -506,3 +506,58 @@ export const loginToServer = async (username: string, password: string): Promise return response.data; } + +/** + * Updates an entity's properties in the knowledge graph + * @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) + * @returns Promise with the updated entity information + */ +export const updateEntity = async ( + entityName: string, + updatedData: Record, + allowRename: boolean = false +): Promise => { + const response = await axiosInstance.post('/graph/entity/edit', { + entity_name: entityName, + updated_data: updatedData, + allow_rename: allowRename + }) + return response.data +} + +/** + * Updates a relation's properties in the knowledge graph + * @param sourceEntity The source entity name + * @param targetEntity The target entity name + * @param updatedData Dictionary containing updated attributes + * @returns Promise with the updated relation information + */ +export const updateRelation = async ( + sourceEntity: string, + targetEntity: string, + updatedData: Record +): Promise => { + const response = await axiosInstance.post('/graph/relation/edit', { + source_entity: sourceEntity, + target_entity: targetEntity, + updated_data: updatedData + }) + return response.data +} + +/** + * Checks if an entity name already exists in the knowledge graph + * @param entityName The entity name to check + * @returns Promise with boolean indicating if the entity exists + */ +export const checkEntityNameExists = async (entityName: string): Promise => { + try { + const response = await axiosInstance.get(`/graph/entity/exists?name=${encodeURIComponent(entityName)}`) + return response.data.exists + } catch (error) { + console.error('Error checking entity name:', error) + return false + } +} diff --git a/lightrag_webui/src/components/graph/EditablePropertyRow.tsx b/lightrag_webui/src/components/graph/EditablePropertyRow.tsx new file mode 100644 index 00000000..dfd94e49 --- /dev/null +++ b/lightrag_webui/src/components/graph/EditablePropertyRow.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import Text from '@/components/ui/Text' +import Input from '@/components/ui/Input' +import { toast } from 'sonner' +import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag' +import { useGraphStore } from '@/stores/graph' +import { useSettingsStore } from '@/stores/settings' + +interface EditablePropertyRowProps { + name: string + value: any + onClick?: () => void + tooltip?: string + entityId?: string + entityType?: 'node' | 'edge' + sourceId?: string + targetId?: string + onValueChange?: (newValue: any) => void + isEditable?: boolean +} + +/** + * EditablePropertyRow component that supports double-click to edit property values + * Specifically designed for editing 'description' and entity name fields + */ +const EditablePropertyRow = ({ + name, + value, + onClick, + tooltip, + entityId, + entityType, + sourceId, + targetId, + onValueChange, + isEditable = false +}: EditablePropertyRowProps) => { + const { t } = useTranslation() + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const inputRef = useRef(null) + + // Initialize edit value when entering edit mode + useEffect(() => { + if (isEditing) { + setEditValue(String(value)) + // Focus the input element when entering edit mode + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, 50) + } + }, [isEditing, value]) + + const getPropertyNameTranslation = (propName: string) => { + const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}` + const translation = t(translationKey) + return translation === translationKey ? propName : translation + } + + const handleDoubleClick = () => { + if (isEditable && !isEditing) { + setIsEditing(true) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave() + } else if (e.key === 'Escape') { + setIsEditing(false) + } + } + + const handleSave = async () => { + if (isSubmitting) return + + // Don't save if value hasn't changed + if (editValue === String(value)) { + setIsEditing(false) + return + } + + setIsSubmitting(true) + + try { + // Special handling for entity_id (name) field to check for duplicates + if (name === 'entity_id' && entityType === 'node') { + // Ensure we are not checking the original name against itself if it's protected + if (editValue !== String(value)) { + const exists = await checkEntityNameExists(editValue); + if (exists) { + toast.error(t('graphPanel.propertiesView.errors.duplicateName')); + setIsSubmitting(false); + return; + } + } + } + + // Update the entity or relation in the database + if (entityType === 'node' && entityId) { + // For nodes, we need to determine if we're updating the name or description + const updatedData: Record = {} + + if (name === 'entity_id') { + // For entity name updates + updatedData['entity_name'] = editValue + await updateEntity(String(value), updatedData, true) // Pass original name (value) as identifier + + // Update node label in the graph directly instead of reloading the entire graph + const sigmaInstance = useGraphStore.getState().sigmaInstance + const sigmaGraph = useGraphStore.getState().sigmaGraph + const rawGraph = useGraphStore.getState().rawGraph + + if (sigmaInstance && sigmaGraph && rawGraph) { + // Update the node in sigma graph + if (sigmaGraph.hasNode(String(value))) { + // Update the node label in the sigma graph + sigmaGraph.setNodeAttribute(String(value), 'label', editValue) + + // Also update the node in the raw graph + const nodeIndex = rawGraph.nodeIdMap[String(value)] + if (nodeIndex !== undefined) { + rawGraph.nodes[nodeIndex].id = editValue + // Update the node ID map + delete rawGraph.nodeIdMap[String(value)] + rawGraph.nodeIdMap[editValue] = nodeIndex + } + + // Refresh the sigma instance to reflect changes + sigmaInstance.refresh() + + // Update selected node ID if it was the edited node + const selectedNode = useGraphStore.getState().selectedNode + if (selectedNode === String(value)) { + useGraphStore.getState().setSelectedNode(editValue) + } + } + } else { + // Fallback to full graph reload if direct update is not possible + useGraphStore.getState().setGraphDataFetchAttempted(false) + useGraphStore.getState().setLabelsFetchAttempted(false) + + // Get current label to trigger reload + const currentLabel = useSettingsStore.getState().queryLabel + if (currentLabel) { + // Trigger data reload by temporarily clearing and resetting the label + useSettingsStore.getState().setQueryLabel('') + setTimeout(() => { + useSettingsStore.getState().setQueryLabel(currentLabel) + }, 0) + } + } + } else if (name === 'description') { + // For description updates + updatedData['description'] = editValue + await updateEntity(entityId, updatedData) // Pass entityId as identifier + } else { + // For other property updates + updatedData[name] = editValue + await updateEntity(entityId, updatedData) // Pass entityId as identifier + } + + toast.success(t('graphPanel.propertiesView.success.entityUpdated')) + } else if (entityType === 'edge' && sourceId && targetId) { + // For edges, update the relation + const updatedData: Record = {} + updatedData[name] = editValue + await updateRelation(sourceId, targetId, updatedData) + toast.success(t('graphPanel.propertiesView.success.relationUpdated')) + } + + // Notify parent component about the value change + if (onValueChange) { + onValueChange(editValue) + } + } catch (error: any) { // Keep type as any to access potential response properties + console.error('Error updating property:', error); + + // Attempt to extract a more specific error message + let detailMessage = t('graphPanel.propertiesView.errors.updateFailed'); // Default message + if (error.response?.data?.detail) { + // Use the detailed message from the backend response if available + detailMessage = error.response.data.detail; + } else if (error.message) { + // Use the error object's message if no backend detail + detailMessage = error.message; + } + + toast.error(detailMessage); // Show the determined error message + + } finally { + setIsSubmitting(false) + setIsEditing(false) + } + } + + // Determine if this property should be editable + // Currently only 'description' and 'entity_id' fields are editable + const isEditableField = isEditable && (name === 'description' || name === 'entity_id') + + return ( +
+ + {getPropertyNameTranslation(name)} + : + {isEditing ? ( +
+ setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + disabled={isSubmitting} + /> +
+ ) : ( + // Wrap Text component in a div to handle onDoubleClick +
+ +
+ )} +
+ ) +} + +export default EditablePropertyRow diff --git a/lightrag_webui/src/components/graph/PropertiesView.tsx b/lightrag_webui/src/components/graph/PropertiesView.tsx index 3aa248de..c2cda263 100644 --- a/lightrag_webui/src/components/graph/PropertiesView.tsx +++ b/lightrag_webui/src/components/graph/PropertiesView.tsx @@ -5,6 +5,7 @@ import Button from '@/components/ui/Button' import useLightragGraph from '@/hooks/useLightragGraph' import { useTranslation } from 'react-i18next' import { GitBranchPlus, Scissors } from 'lucide-react' +import EditablePropertyRow from './EditablePropertyRow' /** * Component that view properties of elements in graph. @@ -169,12 +170,22 @@ const PropertyRow = ({ name, value, onClick, - tooltip + tooltip, + entityId, + entityType, + sourceId, + targetId, + isEditable = false }: { name: string value: any onClick?: () => void tooltip?: string + entityId?: string + entityType?: 'node' | 'edge' + sourceId?: string + targetId?: string + isEditable?: boolean }) => { const { t } = useTranslation() @@ -184,8 +195,24 @@ const PropertyRow = ({ return translation === translationKey ? name : translation } - // Since Text component uses a label internally, we'll use a span here instead of a label - // to avoid nesting labels which is not recommended for accessibility + // Use EditablePropertyRow for editable fields (description and entity_id) + if (isEditable && (name === 'description' || name === 'entity_id')) { + return ( + + ) + } + + // For non-editable fields, use the regular Text component return (
{getPropertyNameTranslation(name)}: @@ -253,7 +280,16 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => { {Object.keys(node.properties) .sort() .map((name) => { - return + return ( + + ) })}
{node.relationships.length > 0 && ( @@ -309,7 +345,18 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => { {Object.keys(edge.properties) .sort() .map((name) => { - return + return ( + + ) })} diff --git a/lightrag_webui/src/lib/constants.ts b/lightrag_webui/src/lib/constants.ts index 048ae8f7..87db8cea 100644 --- a/lightrag_webui/src/lib/constants.ts +++ b/lightrag_webui/src/lib/constants.ts @@ -1,6 +1,6 @@ import { ButtonVariantType } from '@/components/ui/Button' -export const backendBaseUrl = '' +export const backendBaseUrl = 'http://localhost:9621' export const webuiPrefix = '/webui/' export const controlButtonVariant: ButtonVariantType = 'ghost' diff --git a/lightrag_webui/src/stores/graph.ts b/lightrag_webui/src/stores/graph.ts index c50cff41..84000170 100644 --- a/lightrag_webui/src/stores/graph.ts +++ b/lightrag_webui/src/stores/graph.ts @@ -116,6 +116,10 @@ interface GraphState { // Node operation state nodeToExpand: string | null nodeToPrune: string | null + + // Version counter to trigger data refresh + graphDataVersion: number + incrementGraphDataVersion: () => void } const useGraphStoreBase = create()((set) => ({ @@ -219,6 +223,10 @@ const useGraphStoreBase = create()((set) => ({ triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }), triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }), + // Version counter implementation + graphDataVersion: 0, + incrementGraphDataVersion: () => set((state) => ({ graphDataVersion: state.graphDataVersion + 1 })), + })) const useGraphStore = createSelectors(useGraphStoreBase)