Merge branch 'edit-node' into add-graph-db-lock

This commit is contained in:
yangdx
2025-04-14 03:39:57 +08:00
10 changed files with 681 additions and 10 deletions

View File

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

View File

@@ -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<string, any>,
allowRename: boolean = false
): Promise<DocActionResponse> => {
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<string, any>
): Promise<DocActionResponse> => {
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<boolean> => {
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
}
}

View File

@@ -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<HTMLInputElement>(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 (
<div className="flex items-center gap-1" onDoubleClick={handleDoubleClick}>
{/* Property name with translation */}
<span className="text-primary/60 tracking-wide whitespace-nowrap">
{getPropertyNameTranslation(name)}
</span>
{/* Edit icon with tooltip */}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<PencilIcon
className="h-3 w-3 text-gray-500 hover:text-gray-700 cursor-pointer"
onClick={() => setIsEditing(true)}
/>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{t('graphPanel.propertiesView.doubleClickToEdit')}
</TooltipContent>
</Tooltip>
</TooltipProvider>:
{/* Conditional rendering based on edit state */}
{isEditing ? (
// Input field for editing
<Input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className="h-6 text-xs"
disabled={isSubmitting}
/>
) : (
// Text display when not editing
<div className="flex items-center gap-1">
<Text
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis"
tooltipClassName="max-w-80"
text={currentValue}
tooltip={tooltip || (typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2))}
side="left"
onClick={onClick}
/>
</div>
)}
</div>
)
}
export default EditablePropertyRow

View File

@@ -99,8 +99,11 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const events: Record<string, any> = {
enterNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
const graph = sigma.getGraph()
if (graph.hasNode(event.node)) {
setFocusedNode(event.node)
}
}
},
leaveNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
@@ -108,8 +111,11 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
}
},
clickNode: (event: NodeEvent) => {
const graph = sigma.getGraph()
if (graph.hasNode(event.node)) {
setSelectedNode(event.node)
setSelectedEdge(null)
}
},
clickStage: () => clearSelection()
}

View File

@@ -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 (
<EditablePropertyRow
name={name}
value={value}
onClick={onClick}
tooltip={tooltip}
entityId={entityId}
entityType={entityType}
sourceId={sourceId}
targetId={targetId}
isEditable={true}
/>
)
}
// For non-editable fields, use the regular Text component
return (
<div className="flex items-center gap-2">
<span className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</span>:
@@ -253,7 +280,16 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
{Object.keys(node.properties)
.sort()
.map((name) => {
return <PropertyRow key={name} name={name} value={node.properties[name]} />
return (
<PropertyRow
key={name}
name={name}
value={node.properties[name]}
entityId={node.properties['entity_id'] || node.id}
entityType="node"
isEditable={name === 'description' || name === 'entity_id'}
/>
)
})}
</div>
{node.relationships.length > 0 && (
@@ -309,7 +345,18 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
{Object.keys(edge.properties)
.sort()
.map((name) => {
return <PropertyRow key={name} name={name} value={edge.properties[name]} />
return (
<PropertyRow
key={name}
name={name}
value={edge.properties[name]}
entityId={edge.id}
entityType="edge"
sourceId={edge.source}
targetId={edge.target}
isEditable={name === 'description' || name === 'keywords'}
/>
)
})}
</div>
</div>

View File

@@ -236,6 +236,16 @@
"vectorStorage": "تخزين المتجهات"
},
"propertiesView": {
"errors": {
"duplicateName": "اسم العقدة موجود بالفعل",
"updateFailed": "فشل تحديث العقدة",
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا"
},
"doubleClickToEdit": "انقر نقرًا مزدوجًا للتعديل",
"success": {
"entityUpdated": "تم تحديث العقدة بنجاح",
"relationUpdated": "تم تحديث العلاقة بنجاح"
},
"node": {
"title": "عقدة",
"id": "المعرف",

View File

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

View File

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

View File

@@ -236,6 +236,16 @@
"vectorStorage": "向量存储"
},
"propertiesView": {
"errors": {
"duplicateName": "节点名称已存在",
"updateFailed": "更新节点失败",
"tryAgainLater": "请稍后重试"
},
"doubleClickToEdit": "双击编辑",
"success": {
"entityUpdated": "节点更新成功",
"relationUpdated": "关系更新成功"
},
"node": {
"title": "节点",
"id": "ID",

View File

@@ -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<GraphState>()((set) => ({
@@ -219,6 +223,10 @@ const useGraphStoreBase = create<GraphState>()((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)