choizhang commited on
Commit
fa48cea
·
1 Parent(s): 3757a93

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

Browse files
lightrag/api/routers/graph_routes.py CHANGED
@@ -2,14 +2,21 @@
2
  This module contains all graph-related routes for the LightRAG API.
3
  """
4
 
5
- from typing import Optional
6
- from fastapi import APIRouter, Depends, Query
 
7
 
8
  from ..utils_api import get_combined_auth_dependency
9
 
10
  router = APIRouter(tags=["graph"])
11
 
12
 
 
 
 
 
 
 
13
  def create_graph_routes(rag, api_key: Optional[str] = None):
14
  combined_auth = get_combined_auth_dependency(api_key)
15
 
@@ -49,4 +56,55 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
49
  max_nodes=max_nodes,
50
  )
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  return router
 
2
  This module contains all graph-related routes for the LightRAG API.
3
  """
4
 
5
+ from typing import Optional, Dict, Any
6
+ from fastapi import APIRouter, Depends, Query, HTTPException
7
+ from pydantic import BaseModel
8
 
9
  from ..utils_api import get_combined_auth_dependency
10
 
11
  router = APIRouter(tags=["graph"])
12
 
13
 
14
+ class EntityUpdateRequest(BaseModel):
15
+ entity_name: str
16
+ updated_data: Dict[str, Any]
17
+ allow_rename: bool = False
18
+
19
+
20
  def create_graph_routes(rag, api_key: Optional[str] = None):
21
  combined_auth = get_combined_auth_dependency(api_key)
22
 
 
56
  max_nodes=max_nodes,
57
  )
58
 
59
+ @router.get("/graph/entity/exists", dependencies=[Depends(combined_auth)])
60
+ async def check_entity_exists(
61
+ name: str = Query(..., description="Entity name to check"),
62
+ ):
63
+ """
64
+ Check if an entity with the given name exists in the knowledge graph
65
+
66
+ Args:
67
+ name (str): Name of the entity to check
68
+
69
+ Returns:
70
+ Dict[str, bool]: Dictionary with 'exists' key indicating if entity exists
71
+ """
72
+ try:
73
+ exists = await rag.chunk_entity_relation_graph.has_node(name)
74
+ return {"exists": exists}
75
+ except Exception as e:
76
+ raise HTTPException(
77
+ status_code=500, detail=f"Error checking entity existence: {str(e)}"
78
+ )
79
+
80
+ @router.post("/graph/entity/edit", dependencies=[Depends(combined_auth)])
81
+ async def update_entity(request: EntityUpdateRequest):
82
+ """
83
+ Update an entity's properties in the knowledge graph
84
+
85
+ Args:
86
+ request (EntityUpdateRequest): Request containing entity name, updated data, and rename flag
87
+
88
+ Returns:
89
+ Dict: Updated entity information
90
+ """
91
+ try:
92
+ print(request.entity_name, request.updated_data, request.allow_rename)
93
+ result = await rag.aedit_entity(
94
+ entity_name=request.entity_name,
95
+ updated_data=request.updated_data,
96
+ allow_rename=request.allow_rename,
97
+ )
98
+ return {
99
+ "status": "success",
100
+ "message": "Entity updated successfully",
101
+ "data": result,
102
+ }
103
+ except ValueError as ve:
104
+ raise HTTPException(status_code=400, detail=str(ve))
105
+ except Exception as e:
106
+ raise HTTPException(
107
+ status_code=500, detail=f"Error updating entity: {str(e)}"
108
+ )
109
+
110
  return router
lightrag_webui/src/api/lightrag.ts CHANGED
@@ -506,3 +506,58 @@ export const loginToServer = async (username: string, password: string): Promise
506
 
507
  return response.data;
508
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
  return response.data;
508
  }
509
+
510
+ /**
511
+ * Updates an entity's properties in the knowledge graph
512
+ * @param entityName The name of the entity to update
513
+ * @param updatedData Dictionary containing updated attributes
514
+ * @param allowRename Whether to allow renaming the entity (default: false)
515
+ * @returns Promise with the updated entity information
516
+ */
517
+ export const updateEntity = async (
518
+ entityName: string,
519
+ updatedData: Record<string, any>,
520
+ allowRename: boolean = false
521
+ ): Promise<DocActionResponse> => {
522
+ const response = await axiosInstance.post('/graph/entity/edit', {
523
+ entity_name: entityName,
524
+ updated_data: updatedData,
525
+ allow_rename: allowRename
526
+ })
527
+ return response.data
528
+ }
529
+
530
+ /**
531
+ * Updates a relation's properties in the knowledge graph
532
+ * @param sourceEntity The source entity name
533
+ * @param targetEntity The target entity name
534
+ * @param updatedData Dictionary containing updated attributes
535
+ * @returns Promise with the updated relation information
536
+ */
537
+ export const updateRelation = async (
538
+ sourceEntity: string,
539
+ targetEntity: string,
540
+ updatedData: Record<string, any>
541
+ ): Promise<DocActionResponse> => {
542
+ const response = await axiosInstance.post('/graph/relation/edit', {
543
+ source_entity: sourceEntity,
544
+ target_entity: targetEntity,
545
+ updated_data: updatedData
546
+ })
547
+ return response.data
548
+ }
549
+
550
+ /**
551
+ * Checks if an entity name already exists in the knowledge graph
552
+ * @param entityName The entity name to check
553
+ * @returns Promise with boolean indicating if the entity exists
554
+ */
555
+ export const checkEntityNameExists = async (entityName: string): Promise<boolean> => {
556
+ try {
557
+ const response = await axiosInstance.get(`/graph/entity/exists?name=${encodeURIComponent(entityName)}`)
558
+ return response.data.exists
559
+ } catch (error) {
560
+ console.error('Error checking entity name:', error)
561
+ return false
562
+ }
563
+ }
lightrag_webui/src/components/graph/EditablePropertyRow.tsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import Text from '@/components/ui/Text'
4
+ import Input from '@/components/ui/Input'
5
+ import { toast } from 'sonner'
6
+ import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
7
+ import { useGraphStore } from '@/stores/graph'
8
+ import { useSettingsStore } from '@/stores/settings'
9
+
10
+ interface EditablePropertyRowProps {
11
+ name: string
12
+ value: any
13
+ onClick?: () => void
14
+ tooltip?: string
15
+ entityId?: string
16
+ entityType?: 'node' | 'edge'
17
+ sourceId?: string
18
+ targetId?: string
19
+ onValueChange?: (newValue: any) => void
20
+ isEditable?: boolean
21
+ }
22
+
23
+ /**
24
+ * EditablePropertyRow component that supports double-click to edit property values
25
+ * Specifically designed for editing 'description' and entity name fields
26
+ */
27
+ const EditablePropertyRow = ({
28
+ name,
29
+ value,
30
+ onClick,
31
+ tooltip,
32
+ entityId,
33
+ entityType,
34
+ sourceId,
35
+ targetId,
36
+ onValueChange,
37
+ isEditable = false
38
+ }: EditablePropertyRowProps) => {
39
+ const { t } = useTranslation()
40
+ const [isEditing, setIsEditing] = useState(false)
41
+ const [editValue, setEditValue] = useState('')
42
+ const [isSubmitting, setIsSubmitting] = useState(false)
43
+ const inputRef = useRef<HTMLInputElement>(null)
44
+
45
+ // Initialize edit value when entering edit mode
46
+ useEffect(() => {
47
+ if (isEditing) {
48
+ setEditValue(String(value))
49
+ // Focus the input element when entering edit mode
50
+ setTimeout(() => {
51
+ if (inputRef.current) {
52
+ inputRef.current.focus()
53
+ inputRef.current.select()
54
+ }
55
+ }, 50)
56
+ }
57
+ }, [isEditing, value])
58
+
59
+ const getPropertyNameTranslation = (propName: string) => {
60
+ const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}`
61
+ const translation = t(translationKey)
62
+ return translation === translationKey ? propName : translation
63
+ }
64
+
65
+ const handleDoubleClick = () => {
66
+ if (isEditable && !isEditing) {
67
+ setIsEditing(true)
68
+ }
69
+ }
70
+
71
+ const handleKeyDown = (e: React.KeyboardEvent) => {
72
+ if (e.key === 'Enter') {
73
+ handleSave()
74
+ } else if (e.key === 'Escape') {
75
+ setIsEditing(false)
76
+ }
77
+ }
78
+
79
+ const handleSave = async () => {
80
+ if (isSubmitting) return
81
+
82
+ // Don't save if value hasn't changed
83
+ if (editValue === String(value)) {
84
+ setIsEditing(false)
85
+ return
86
+ }
87
+
88
+ setIsSubmitting(true)
89
+
90
+ try {
91
+ // Special handling for entity_id (name) field to check for duplicates
92
+ if (name === 'entity_id' && entityType === 'node') {
93
+ // Ensure we are not checking the original name against itself if it's protected
94
+ if (editValue !== String(value)) {
95
+ const exists = await checkEntityNameExists(editValue);
96
+ if (exists) {
97
+ toast.error(t('graphPanel.propertiesView.errors.duplicateName'));
98
+ setIsSubmitting(false);
99
+ return;
100
+ }
101
+ }
102
+ }
103
+
104
+ // Update the entity or relation in the database
105
+ if (entityType === 'node' && entityId) {
106
+ // For nodes, we need to determine if we're updating the name or description
107
+ const updatedData: Record<string, any> = {}
108
+
109
+ if (name === 'entity_id') {
110
+ // For entity name updates
111
+ updatedData['entity_name'] = editValue
112
+ await updateEntity(String(value), updatedData, true) // Pass original name (value) as identifier
113
+
114
+ // Update node label in the graph directly instead of reloading the entire graph
115
+ const sigmaInstance = useGraphStore.getState().sigmaInstance
116
+ const sigmaGraph = useGraphStore.getState().sigmaGraph
117
+ const rawGraph = useGraphStore.getState().rawGraph
118
+
119
+ if (sigmaInstance && sigmaGraph && rawGraph) {
120
+ // Update the node in sigma graph
121
+ if (sigmaGraph.hasNode(String(value))) {
122
+ // Update the node label in the sigma graph
123
+ sigmaGraph.setNodeAttribute(String(value), 'label', editValue)
124
+
125
+ // Also update the node in the raw graph
126
+ const nodeIndex = rawGraph.nodeIdMap[String(value)]
127
+ if (nodeIndex !== undefined) {
128
+ rawGraph.nodes[nodeIndex].id = editValue
129
+ // Update the node ID map
130
+ delete rawGraph.nodeIdMap[String(value)]
131
+ rawGraph.nodeIdMap[editValue] = nodeIndex
132
+ }
133
+
134
+ // Refresh the sigma instance to reflect changes
135
+ sigmaInstance.refresh()
136
+
137
+ // Update selected node ID if it was the edited node
138
+ const selectedNode = useGraphStore.getState().selectedNode
139
+ if (selectedNode === String(value)) {
140
+ useGraphStore.getState().setSelectedNode(editValue)
141
+ }
142
+ }
143
+ } else {
144
+ // Fallback to full graph reload if direct update is not possible
145
+ useGraphStore.getState().setGraphDataFetchAttempted(false)
146
+ useGraphStore.getState().setLabelsFetchAttempted(false)
147
+
148
+ // Get current label to trigger reload
149
+ const currentLabel = useSettingsStore.getState().queryLabel
150
+ if (currentLabel) {
151
+ // Trigger data reload by temporarily clearing and resetting the label
152
+ useSettingsStore.getState().setQueryLabel('')
153
+ setTimeout(() => {
154
+ useSettingsStore.getState().setQueryLabel(currentLabel)
155
+ }, 0)
156
+ }
157
+ }
158
+ } else if (name === 'description') {
159
+ // For description updates
160
+ updatedData['description'] = editValue
161
+ await updateEntity(entityId, updatedData) // Pass entityId as identifier
162
+ } else {
163
+ // For other property updates
164
+ updatedData[name] = editValue
165
+ await updateEntity(entityId, updatedData) // Pass entityId as identifier
166
+ }
167
+
168
+ toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
169
+ } else if (entityType === 'edge' && sourceId && targetId) {
170
+ // For edges, update the relation
171
+ const updatedData: Record<string, any> = {}
172
+ updatedData[name] = editValue
173
+ await updateRelation(sourceId, targetId, updatedData)
174
+ toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
175
+ }
176
+
177
+ // Notify parent component about the value change
178
+ if (onValueChange) {
179
+ onValueChange(editValue)
180
+ }
181
+ } catch (error: any) { // Keep type as any to access potential response properties
182
+ console.error('Error updating property:', error);
183
+
184
+ // Attempt to extract a more specific error message
185
+ let detailMessage = t('graphPanel.propertiesView.errors.updateFailed'); // Default message
186
+ if (error.response?.data?.detail) {
187
+ // Use the detailed message from the backend response if available
188
+ detailMessage = error.response.data.detail;
189
+ } else if (error.message) {
190
+ // Use the error object's message if no backend detail
191
+ detailMessage = error.message;
192
+ }
193
+
194
+ toast.error(detailMessage); // Show the determined error message
195
+
196
+ } finally {
197
+ setIsSubmitting(false)
198
+ setIsEditing(false)
199
+ }
200
+ }
201
+
202
+ // Determine if this property should be editable
203
+ // Currently only 'description' and 'entity_id' fields are editable
204
+ const isEditableField = isEditable && (name === 'description' || name === 'entity_id')
205
+
206
+ return (
207
+ <div className="flex items-center gap-2">
208
+ <span className="text-primary/60 tracking-wide whitespace-nowrap">
209
+ {getPropertyNameTranslation(name)}
210
+ </span>:
211
+ {isEditing ? (
212
+ <div className="flex-1">
213
+ <Input
214
+ ref={inputRef}
215
+ className="h-7 text-xs w-full"
216
+ value={editValue}
217
+ onChange={(e) => setEditValue(e.target.value)}
218
+ onBlur={handleSave}
219
+ onKeyDown={handleKeyDown}
220
+ disabled={isSubmitting}
221
+ />
222
+ </div>
223
+ ) : (
224
+ // Wrap Text component in a div to handle onDoubleClick
225
+ <div
226
+ className={`flex-1 overflow-hidden ${isEditableField ? 'cursor-text' : ''}`} // Apply cursor style to wrapper
227
+ onDoubleClick={isEditableField ? handleDoubleClick : undefined}
228
+ >
229
+ <Text
230
+ className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis block w-full" // Ensure Text fills the div
231
+ tooltipClassName="max-w-80"
232
+ // Ensure the text prop always receives a string representation
233
+ text={String(value)}
234
+ tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
235
+ side="left"
236
+ onClick={onClick}
237
+ // Removed onDoubleClick from Text component
238
+ />
239
+ </div>
240
+ )}
241
+ </div>
242
+ )
243
+ }
244
+
245
+ export default EditablePropertyRow
lightrag_webui/src/components/graph/PropertiesView.tsx CHANGED
@@ -5,6 +5,7 @@ import Button from '@/components/ui/Button'
5
  import useLightragGraph from '@/hooks/useLightragGraph'
6
  import { useTranslation } from 'react-i18next'
7
  import { GitBranchPlus, Scissors } from 'lucide-react'
 
8
 
9
  /**
10
  * Component that view properties of elements in graph.
@@ -169,12 +170,22 @@ const PropertyRow = ({
169
  name,
170
  value,
171
  onClick,
172
- tooltip
 
 
 
 
 
173
  }: {
174
  name: string
175
  value: any
176
  onClick?: () => void
177
  tooltip?: string
 
 
 
 
 
178
  }) => {
179
  const { t } = useTranslation()
180
 
@@ -184,8 +195,24 @@ const PropertyRow = ({
184
  return translation === translationKey ? name : translation
185
  }
186
 
187
- // Since Text component uses a label internally, we'll use a span here instead of a label
188
- // to avoid nesting labels which is not recommended for accessibility
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  return (
190
  <div className="flex items-center gap-2">
191
  <span className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</span>:
@@ -253,7 +280,16 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
253
  {Object.keys(node.properties)
254
  .sort()
255
  .map((name) => {
256
- return <PropertyRow key={name} name={name} value={node.properties[name]} />
 
 
 
 
 
 
 
 
 
257
  })}
258
  </div>
259
  {node.relationships.length > 0 && (
@@ -309,7 +345,18 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
309
  {Object.keys(edge.properties)
310
  .sort()
311
  .map((name) => {
312
- return <PropertyRow key={name} name={name} value={edge.properties[name]} />
 
 
 
 
 
 
 
 
 
 
 
313
  })}
314
  </div>
315
  </div>
 
5
  import useLightragGraph from '@/hooks/useLightragGraph'
6
  import { useTranslation } from 'react-i18next'
7
  import { GitBranchPlus, Scissors } from 'lucide-react'
8
+ import EditablePropertyRow from './EditablePropertyRow'
9
 
10
  /**
11
  * Component that view properties of elements in graph.
 
170
  name,
171
  value,
172
  onClick,
173
+ tooltip,
174
+ entityId,
175
+ entityType,
176
+ sourceId,
177
+ targetId,
178
+ isEditable = false
179
  }: {
180
  name: string
181
  value: any
182
  onClick?: () => void
183
  tooltip?: string
184
+ entityId?: string
185
+ entityType?: 'node' | 'edge'
186
+ sourceId?: string
187
+ targetId?: string
188
+ isEditable?: boolean
189
  }) => {
190
  const { t } = useTranslation()
191
 
 
195
  return translation === translationKey ? name : translation
196
  }
197
 
198
+ // Use EditablePropertyRow for editable fields (description and entity_id)
199
+ if (isEditable && (name === 'description' || name === 'entity_id')) {
200
+ return (
201
+ <EditablePropertyRow
202
+ name={name}
203
+ value={value}
204
+ onClick={onClick}
205
+ tooltip={tooltip}
206
+ entityId={entityId}
207
+ entityType={entityType}
208
+ sourceId={sourceId}
209
+ targetId={targetId}
210
+ isEditable={true}
211
+ />
212
+ )
213
+ }
214
+
215
+ // For non-editable fields, use the regular Text component
216
  return (
217
  <div className="flex items-center gap-2">
218
  <span className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</span>:
 
280
  {Object.keys(node.properties)
281
  .sort()
282
  .map((name) => {
283
+ return (
284
+ <PropertyRow
285
+ key={name}
286
+ name={name}
287
+ value={node.properties[name]}
288
+ entityId={node.properties['entity_id'] || node.id}
289
+ entityType="node"
290
+ isEditable={name === 'description' || name === 'entity_id'}
291
+ />
292
+ )
293
  })}
294
  </div>
295
  {node.relationships.length > 0 && (
 
345
  {Object.keys(edge.properties)
346
  .sort()
347
  .map((name) => {
348
+ return (
349
+ <PropertyRow
350
+ key={name}
351
+ name={name}
352
+ value={edge.properties[name]}
353
+ entityId={edge.id}
354
+ entityType="edge"
355
+ sourceId={edge.sourceNode?.properties['entity_id'] || edge.source}
356
+ targetId={edge.targetNode?.properties['entity_id'] || edge.target}
357
+ isEditable={name === 'description'}
358
+ />
359
+ )
360
  })}
361
  </div>
362
  </div>
lightrag_webui/src/lib/constants.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { ButtonVariantType } from '@/components/ui/Button'
2
 
3
- export const backendBaseUrl = ''
4
  export const webuiPrefix = '/webui/'
5
 
6
  export const controlButtonVariant: ButtonVariantType = 'ghost'
 
1
  import { ButtonVariantType } from '@/components/ui/Button'
2
 
3
+ export const backendBaseUrl = 'http://localhost:9621'
4
  export const webuiPrefix = '/webui/'
5
 
6
  export const controlButtonVariant: ButtonVariantType = 'ghost'
lightrag_webui/src/stores/graph.ts CHANGED
@@ -116,6 +116,10 @@ interface GraphState {
116
  // Node operation state
117
  nodeToExpand: string | null
118
  nodeToPrune: string | null
 
 
 
 
119
  }
120
 
121
  const useGraphStoreBase = create<GraphState>()((set) => ({
@@ -219,6 +223,10 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
219
  triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),
220
  triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),
221
 
 
 
 
 
222
  }))
223
 
224
  const useGraphStore = createSelectors(useGraphStoreBase)
 
116
  // Node operation state
117
  nodeToExpand: string | null
118
  nodeToPrune: string | null
119
+
120
+ // Version counter to trigger data refresh
121
+ graphDataVersion: number
122
+ incrementGraphDataVersion: () => void
123
  }
124
 
125
  const useGraphStoreBase = create<GraphState>()((set) => ({
 
223
  triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),
224
  triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),
225
 
226
+ // Version counter implementation
227
+ graphDataVersion: 0,
228
+ incrementGraphDataVersion: () => set((state) => ({ graphDataVersion: state.graphDataVersion + 1 })),
229
+
230
  }))
231
 
232
  const useGraphStore = createSelectors(useGraphStoreBase)