feat(graph): Add editing function for entity and relationship attributes

This commit is contained in:
choizhang
2025-04-12 00:48:19 +08:00
parent 9487eca772
commit 7e3e685763
6 changed files with 421 additions and 8 deletions

View File

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

View File

@@ -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<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_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<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,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<HTMLInputElement>(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<string, any> = {}
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<string, any> = {}
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 (
<div className="flex items-center gap-2">
<span className="text-primary/60 tracking-wide whitespace-nowrap">
{getPropertyNameTranslation(name)}
</span>:
{isEditing ? (
<div className="flex-1">
<Input
ref={inputRef}
className="h-7 text-xs w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
/>
</div>
) : (
// Wrap Text component in a div to handle onDoubleClick
<div
className={`flex-1 overflow-hidden ${isEditableField ? 'cursor-text' : ''}`} // Apply cursor style to wrapper
onDoubleClick={isEditableField ? handleDoubleClick : undefined}
>
<Text
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis block w-full" // Ensure Text fills the div
tooltipClassName="max-w-80"
// Ensure the text prop always receives a string representation
text={String(value)}
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
side="left"
onClick={onClick}
// Removed onDoubleClick from Text component
/>
</div>
)}
</div>
)
}
export default EditablePropertyRow

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 and entity_id)
if (isEditable && (name === 'description' || name === 'entity_id')) {
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.sourceNode?.properties['entity_id'] || edge.source}
targetId={edge.targetNode?.properties['entity_id'] || edge.target}
isEditable={name === 'description'}
/>
)
})}
</div>
</div>

View File

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

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)