diff --git a/lightrag/api/routers/graph_routes.py b/lightrag/api/routers/graph_routes.py index 381df90b..e77e959a 100644 --- a/lightrag/api/routers/graph_routes.py +++ b/lightrag/api/routers/graph_routes.py @@ -2,14 +2,27 @@ 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 + + +class RelationUpdateRequest(BaseModel): + source_id: str + target_id: str + updated_data: Dict[str, Any] + + def create_graph_routes(rag, api_key: Optional[str] = None): combined_auth = get_combined_auth_dependency(api_key) @@ -49,4 +62,83 @@ 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)}" + ) + + @router.post("/graph/relation/edit", dependencies=[Depends(combined_auth)]) + async def update_relation(request: RelationUpdateRequest): + """Update a relation's properties in the knowledge graph + + Args: + request (RelationUpdateRequest): Request containing source ID, target ID and updated data + + Returns: + Dict: Updated relation information + """ + try: + result = await rag.aedit_relation( + source_entity=request.source_id, + target_entity=request.target_id, + updated_data=request.updated_data, + ) + return { + "status": "success", + "message": "Relation 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 relation: {str(e)}" + ) + return router diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index d6bae0c0..641b8486 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -507,3 +507,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_id: sourceEntity, + target_id: 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..bfd88802 --- /dev/null +++ b/lightrag_webui/src/components/graph/EditablePropertyRow.tsx @@ -0,0 +1,423 @@ +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 { PencilIcon } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip' + +/** + * Interface for the EditablePropertyRow component props + * Defines all possible properties that can be passed to the component + */ +interface EditablePropertyRowProps { + name: string // Property name to display and edit + value: any // Initial value of the property + onClick?: () => void // Optional click handler for the property value + tooltip?: string // Optional tooltip text + entityId?: string // ID of the entity (for node type) + entityType?: 'node' | 'edge' // Type of graph entity + sourceId?: string // Source node ID (for edge type) + targetId?: string // Target node ID (for edge type) + onValueChange?: (newValue: any) => void // Optional callback when value changes + isEditable?: boolean // Whether this property can be edited +} + +/** + * Interface for tracking edges that need updating when a node ID changes + */ +interface EdgeToUpdate { + originalDynamicId: string; + newEdgeId: string; + edgeIndex: number; +} + +/** + * EditablePropertyRow component that supports double-click to edit property values + * This component is used in the graph properties panel to display and edit entity properties + * + * @component + */ +const EditablePropertyRow = ({ + name, + value: initialValue, + onClick, + tooltip, + entityId, + entityType, + sourceId, + targetId, + onValueChange, + isEditable = false +}: EditablePropertyRowProps) => { + // Component state + const { t } = useTranslation() + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [currentValue, setCurrentValue] = useState(initialValue) + const inputRef = useRef(null) + + /** + * Update currentValue when initialValue changes from parent + */ + useEffect(() => { + setCurrentValue(initialValue) + }, [initialValue]) + + /** + * Initialize edit value and focus input when entering edit mode + */ + useEffect(() => { + if (isEditing) { + setEditValue(String(currentValue)) + // Focus the input element when entering edit mode with a small delay + // to ensure the input is rendered before focusing + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, 50) + } + }, [isEditing, currentValue]) + + /** + * Get translated property name from i18n + * Falls back to the original name if no translation is found + */ + const getPropertyNameTranslation = (propName: string) => { + const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}` + const translation = t(translationKey) + return translation === translationKey ? propName : translation + } + + /** + * Handle double-click event to enter edit mode + */ + const handleDoubleClick = () => { + if (isEditable && !isEditing) { + setIsEditing(true) + } + } + + /** + * Handle keyboard events in the input field + * - Enter: Save changes + * - Escape: Cancel editing + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave() + } else if (e.key === 'Escape') { + setIsEditing(false) + } + } + + /** + * Update node in the graph visualization after API update + * Handles both property updates and entity ID changes + * + * @param nodeId - ID of the node to update + * @param propertyName - Name of the property being updated + * @param newValue - New value for the property + */ + const updateGraphNode = async (nodeId: string, propertyName: string, newValue: string) => { + // Get graph state from store + const sigmaInstance = useGraphStore.getState().sigmaInstance + const sigmaGraph = useGraphStore.getState().sigmaGraph + const rawGraph = useGraphStore.getState().rawGraph + + // Validate graph state + if (!sigmaInstance || !sigmaGraph || !rawGraph || !sigmaGraph.hasNode(String(nodeId))) { + return + } + + try { + const nodeAttributes = sigmaGraph.getNodeAttributes(String(nodeId)) + + // Special handling for entity_id changes (node renaming) + if (propertyName === 'entity_id') { + // Create new node with updated ID but same attributes + sigmaGraph.addNode(newValue, { ...nodeAttributes, label: newValue }) + + const edgesToUpdate: EdgeToUpdate[] = []; + + // Process all edges connected to this node + sigmaGraph.forEachEdge(String(nodeId), (edge, attributes, source, target) => { + const otherNode = source === String(nodeId) ? target : source + const isOutgoing = source === String(nodeId) + + // Get original edge dynamic ID for later reference + const originalEdgeDynamicId = edge + const edgeIndexInRawGraph = rawGraph.edgeDynamicIdMap[originalEdgeDynamicId] + + // Create new edge with updated node reference + const newEdgeId = sigmaGraph.addEdge( + isOutgoing ? newValue : otherNode, + isOutgoing ? otherNode : newValue, + attributes + ) + + // Track edges that need updating in the raw graph + if (edgeIndexInRawGraph !== undefined) { + edgesToUpdate.push({ + originalDynamicId: originalEdgeDynamicId, + newEdgeId: newEdgeId, + edgeIndex: edgeIndexInRawGraph + }) + } + + // Remove the old edge + sigmaGraph.dropEdge(edge) + }) + + // Remove the old node after all edges are processed + sigmaGraph.dropNode(String(nodeId)) + + // Update node reference in raw graph data + const nodeIndex = rawGraph.nodeIdMap[String(nodeId)] + if (nodeIndex !== undefined) { + rawGraph.nodes[nodeIndex].id = newValue + rawGraph.nodes[nodeIndex].properties.entity_id = newValue + delete rawGraph.nodeIdMap[String(nodeId)] + rawGraph.nodeIdMap[newValue] = nodeIndex + } + + // Update all edge references in raw graph data + edgesToUpdate.forEach(({ originalDynamicId, newEdgeId, edgeIndex }) => { + if (rawGraph.edges[edgeIndex]) { + // Update source/target references + if (rawGraph.edges[edgeIndex].source === String(nodeId)) { + rawGraph.edges[edgeIndex].source = newValue + } + if (rawGraph.edges[edgeIndex].target === String(nodeId)) { + rawGraph.edges[edgeIndex].target = newValue + } + + // Update dynamic ID mappings + rawGraph.edges[edgeIndex].dynamicId = newEdgeId + delete rawGraph.edgeDynamicIdMap[originalDynamicId] + rawGraph.edgeDynamicIdMap[newEdgeId] = edgeIndex + } + }) + + // Update selected node in store + useGraphStore.getState().setSelectedNode(newValue) + } else { + // For other properties, just update the property in raw graph + const nodeIndex = rawGraph.nodeIdMap[String(nodeId)] + if (nodeIndex !== undefined) { + rawGraph.nodes[nodeIndex].properties[propertyName] = newValue + } + } + } catch (error) { + console.error('Error updating node in graph:', error) + throw new Error('Failed to update node in graph') + } + } + + /** + * Update edge in the graph visualization after API update + * + * @param sourceId - ID of the source node + * @param targetId - ID of the target node + * @param propertyName - Name of the property being updated + * @param newValue - New value for the property + */ + const updateGraphEdge = async (sourceId: string, targetId: string, propertyName: string, newValue: string) => { + // Get graph state from store + const sigmaInstance = useGraphStore.getState().sigmaInstance + const sigmaGraph = useGraphStore.getState().sigmaGraph + const rawGraph = useGraphStore.getState().rawGraph + + // Validate graph state + if (!sigmaInstance || !sigmaGraph || !rawGraph) { + return + } + + try { + // Find the edge between source and target nodes + const allEdges = sigmaGraph.edges() + let keyToUse = null + + for (const edge of allEdges) { + const edgeSource = sigmaGraph.source(edge) + const edgeTarget = sigmaGraph.target(edge) + + // Match edge in either direction (undirected graph support) + if ((edgeSource === sourceId && edgeTarget === targetId) || + (edgeSource === targetId && edgeTarget === sourceId)) { + keyToUse = edge + break + } + } + + if (keyToUse !== null) { + // Special handling for keywords property (updates edge label) + if(propertyName === 'keywords') { + sigmaGraph.setEdgeAttribute(keyToUse, 'label', newValue); + } else { + sigmaGraph.setEdgeAttribute(keyToUse, propertyName, newValue); + } + + // Update edge in raw graph data using dynamic ID mapping + if (keyToUse && rawGraph.edgeDynamicIdMap[keyToUse] !== undefined) { + const edgeIndex = rawGraph.edgeDynamicIdMap[keyToUse]; + if (rawGraph.edges[edgeIndex]) { + rawGraph.edges[edgeIndex].properties[propertyName] = newValue; + } else { + console.warn(`Edge index ${edgeIndex} found but edge data missing in rawGraph for dynamicId ${entityId}`); + } + } else { + // Fallback: try to find edge by key in edge ID map + console.warn(`Could not find edge with dynamicId ${entityId} in rawGraph.edgeDynamicIdMap to update properties.`); + if (keyToUse !== null) { + const edgeIndexByKey = rawGraph.edgeIdMap[keyToUse]; + if (edgeIndexByKey !== undefined && rawGraph.edges[edgeIndexByKey]) { + rawGraph.edges[edgeIndexByKey].properties[propertyName] = newValue; + console.log(`Updated rawGraph edge using constructed key ${keyToUse}`); + } else { + console.warn(`Could not find edge in rawGraph using key ${keyToUse} either.`); + } + } else { + console.warn('Cannot update edge properties: edge key is null'); + } + } + } else { + console.warn(`Edge not found in sigmaGraph with key ${keyToUse}`); + } + } catch (error) { + // Log the specific edge key that caused the error + console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error); + throw new Error('Failed to update edge in graph') + } + } + + /** + * Save changes to the property value + * Updates both the API and the graph visualization + */ + const handleSave = async () => { + // Prevent duplicate submissions + if (isSubmitting) return + + // Skip if value hasn't changed + if (editValue === String(currentValue)) { + setIsEditing(false) + return + } + + setIsSubmitting(true) + + try { + // Handle node property updates + if (entityType === 'node' && entityId) { + let updatedData = { [name]: editValue } + + // Special handling for entity_id (name) changes + if (name === 'entity_id') { + // Check if the new name already exists + const exists = await checkEntityNameExists(editValue) + if (exists) { + toast.error(t('graphPanel.propertiesView.errors.duplicateName')) + setIsSubmitting(false) + return + } + // For entity_id, we update entity_name in the API + updatedData = { 'entity_name': editValue } + } + + // Update entity in API + await updateEntity(entityId, updatedData, true) + // Update graph visualization + await updateGraphNode(entityId, name, editValue) + toast.success(t('graphPanel.propertiesView.success.entityUpdated')) + } + // Handle edge property updates + else if (entityType === 'edge' && sourceId && targetId) { + const updatedData = { [name]: editValue } + // Update relation in API + await updateRelation(sourceId, targetId, updatedData) + // Update graph visualization + await updateGraphEdge(sourceId, targetId, name, editValue) + toast.success(t('graphPanel.propertiesView.success.relationUpdated')) + } + + // Update local state + setIsEditing(false) + setCurrentValue(editValue) + + // Notify parent component if callback provided + if (onValueChange) { + onValueChange(editValue) + } + } catch (error) { + console.error('Error updating property:', error) + toast.error(t('graphPanel.propertiesView.errors.updateFailed')) + } finally { + setIsSubmitting(false) + } + } + + /** + * Render the property row with edit functionality + * Shows property name, edit icon, and either the editable input or the current value + */ + return ( +
+ {/* Property name with translation */} + + {getPropertyNameTranslation(name)} + + + {/* Edit icon with tooltip */} + + + +
+ setIsEditing(true)} + /> +
+
+ + {t('graphPanel.propertiesView.doubleClickToEdit')} + +
+
: + + {/* Conditional rendering based on edit state */} + {isEditing ? ( + // Input field for editing + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSave} + className="h-6 text-xs" + disabled={isSubmitting} + /> + ) : ( + // Text display when not editing +
+ +
+ )} +
+ ) +} + +export default EditablePropertyRow diff --git a/lightrag_webui/src/components/graph/GraphControl.tsx b/lightrag_webui/src/components/graph/GraphControl.tsx index aca8a9c4..8211178a 100644 --- a/lightrag_webui/src/components/graph/GraphControl.tsx +++ b/lightrag_webui/src/components/graph/GraphControl.tsx @@ -99,7 +99,10 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) const events: Record = { enterNode: (event: NodeEvent) => { if (!isButtonPressed(event.event.original)) { - setFocusedNode(event.node) + const graph = sigma.getGraph() + if (graph.hasNode(event.node)) { + setFocusedNode(event.node) + } } }, leaveNode: (event: NodeEvent) => { @@ -108,8 +111,11 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) } }, clickNode: (event: NodeEvent) => { - setSelectedNode(event.node) - setSelectedEdge(null) + const graph = sigma.getGraph() + if (graph.hasNode(event.node)) { + setSelectedNode(event.node) + setSelectedEdge(null) + } }, clickStage: () => clearSelection() } diff --git a/lightrag_webui/src/components/graph/PropertiesView.tsx b/lightrag_webui/src/components/graph/PropertiesView.tsx index 3aa248de..257b69ec 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, entity_id and keywords) + if (isEditable && (name === 'description' || name === 'entity_id' || name === 'keywords')) { + 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/locales/ar.json b/lightrag_webui/src/locales/ar.json index 641315ee..4f603e66 100644 --- a/lightrag_webui/src/locales/ar.json +++ b/lightrag_webui/src/locales/ar.json @@ -236,6 +236,16 @@ "vectorStorage": "تخزين المتجهات" }, "propertiesView": { + "errors": { + "duplicateName": "اسم العقدة موجود بالفعل", + "updateFailed": "فشل تحديث العقدة", + "tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا" + }, + "doubleClickToEdit": "انقر نقرًا مزدوجًا للتعديل", + "success": { + "entityUpdated": "تم تحديث العقدة بنجاح", + "relationUpdated": "تم تحديث العلاقة بنجاح" + }, "node": { "title": "عقدة", "id": "المعرف", diff --git a/lightrag_webui/src/locales/en.json b/lightrag_webui/src/locales/en.json index ff20528d..1ea87f5d 100644 --- a/lightrag_webui/src/locales/en.json +++ b/lightrag_webui/src/locales/en.json @@ -235,6 +235,16 @@ "vectorStorage": "Vector Storage" }, "propertiesView": { + "errors": { + "duplicateName": "Node name already exists", + "updateFailed": "Failed to update node", + "tryAgainLater": "Please try again later" + }, + "doubleClickToEdit": "Double click to edit", + "success": { + "entityUpdated": "Node updated successfully", + "relationUpdated": "Relation updated successfully" + }, "node": { "title": "Node", "id": "ID", diff --git a/lightrag_webui/src/locales/fr.json b/lightrag_webui/src/locales/fr.json index e35f744a..ed5576ae 100644 --- a/lightrag_webui/src/locales/fr.json +++ b/lightrag_webui/src/locales/fr.json @@ -236,6 +236,16 @@ "vectorStorage": "Stockage vectoriel" }, "propertiesView": { + "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" + }, + "doubleClickToEdit": "Double-cliquez pour modifier", + "success": { + "entityUpdated": "Nœud mis à jour avec succès", + "relationUpdated": "Relation mise à jour avec succès" + }, "node": { "title": "Nœud", "id": "ID", diff --git a/lightrag_webui/src/locales/zh.json b/lightrag_webui/src/locales/zh.json index 60e2b7c8..33b86c40 100644 --- a/lightrag_webui/src/locales/zh.json +++ b/lightrag_webui/src/locales/zh.json @@ -236,6 +236,16 @@ "vectorStorage": "向量存储" }, "propertiesView": { + "errors": { + "duplicateName": "节点名称已存在", + "updateFailed": "更新节点失败", + "tryAgainLater": "请稍后重试" + }, + "doubleClickToEdit": "双击编辑", + "success": { + "entityUpdated": "节点更新成功", + "relationUpdated": "关系更新成功" + }, "node": { "title": "节点", "id": "ID", 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)