Merge pull request #1367 from danielaskdd/add-graph-db-lock

Add graph_db_lock to ensure consistency across multiple processes for node and edge edition jobs
This commit is contained in:
Daniel.y
2025-04-14 13:32:25 +08:00
committed by GitHub
22 changed files with 2560 additions and 1420 deletions

View File

@@ -1 +1 @@
__api_version__ = "0149" __api_version__ = "0150"

View File

@@ -2,14 +2,29 @@
This module contains all graph-related routes for the LightRAG API. This module contains all graph-related routes for the LightRAG API.
""" """
from typing import Optional from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, Query import traceback
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from lightrag.utils import logger
from ..utils_api import get_combined_auth_dependency from ..utils_api import get_combined_auth_dependency
router = APIRouter(tags=["graph"]) 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): def create_graph_routes(rag, api_key: Optional[str] = None):
combined_auth = get_combined_auth_dependency(api_key) combined_auth = get_combined_auth_dependency(api_key)
@@ -21,7 +36,14 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
Returns: Returns:
List[str]: List of graph labels List[str]: List of graph labels
""" """
try:
return await rag.get_graph_labels() return await rag.get_graph_labels()
except Exception as e:
logger.error(f"Error getting graph labels: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting graph labels: {str(e)}"
)
@router.get("/graphs", dependencies=[Depends(combined_auth)]) @router.get("/graphs", dependencies=[Depends(combined_auth)])
async def get_knowledge_graph( async def get_knowledge_graph(
@@ -43,10 +65,109 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
Returns: Returns:
Dict[str, List[str]]: Knowledge graph for label Dict[str, List[str]]: Knowledge graph for label
""" """
try:
return await rag.get_knowledge_graph( return await rag.get_knowledge_graph(
node_label=label, node_label=label,
max_depth=max_depth, max_depth=max_depth,
max_nodes=max_nodes, max_nodes=max_nodes,
) )
except Exception as e:
logger.error(f"Error getting knowledge graph for label '{label}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting knowledge graph: {str(e)}"
)
@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:
logger.error(f"Error checking entity existence for '{name}': {str(e)}")
logger.error(traceback.format_exc())
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:
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:
logger.error(
f"Validation error updating entity '{request.entity_name}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(f"Error updating entity '{request.entity_name}': {str(e)}")
logger.error(traceback.format_exc())
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:
logger.error(
f"Validation error updating relation between '{request.source_id}' and '{request.target_id}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(
f"Error updating relation between '{request.source_id}' and '{request.target_id}': {str(e)}"
)
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error updating relation: {str(e)}"
)
return router return router

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,8 @@
<link rel="icon" type="image/svg+xml" href="logo.png" /> <link rel="icon" type="image/svg+xml" href="logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title> <title>Lightrag</title>
<script type="module" crossorigin src="/webui/assets/index-vzpYU2q3.js"></script> <script type="module" crossorigin src="/webui/assets/index-CIRM3gxn.js"></script>
<link rel="stylesheet" crossorigin href="/webui/assets/index-CTB4Vp_z.css"> <link rel="stylesheet" crossorigin href="/webui/assets/index-BJDb04H1.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -893,6 +893,351 @@ def always_get_an_event_loop() -> asyncio.AbstractEventLoop:
return new_loop return new_loop
async def aexport_data(
chunk_entity_relation_graph,
entities_vdb,
relationships_vdb,
output_path: str,
file_format: str = "csv",
include_vector_data: bool = False,
) -> None:
"""
Asynchronously exports all entities, relations, and relationships to various formats.
Args:
chunk_entity_relation_graph: Graph storage instance for entities and relations
entities_vdb: Vector database storage for entities
relationships_vdb: Vector database storage for relationships
output_path: The path to the output file (including extension).
file_format: Output format - "csv", "excel", "md", "txt".
- csv: Comma-separated values file
- excel: Microsoft Excel file with multiple sheets
- md: Markdown tables
- txt: Plain text formatted output
include_vector_data: Whether to include data from the vector database.
"""
# Collect data
entities_data = []
relations_data = []
relationships_data = []
# --- Entities ---
all_entities = await chunk_entity_relation_graph.get_all_labels()
for entity_name in all_entities:
# Get entity information from graph
node_data = await chunk_entity_relation_graph.get_node(entity_name)
source_id = node_data.get("source_id") if node_data else None
entity_info = {
"graph_data": node_data,
"source_id": source_id,
}
# Optional: Get vector database information
if include_vector_data:
entity_id = compute_mdhash_id(entity_name, prefix="ent-")
vector_data = await entities_vdb.get_by_id(entity_id)
entity_info["vector_data"] = vector_data
entity_row = {
"entity_name": entity_name,
"source_id": source_id,
"graph_data": str(
entity_info["graph_data"]
), # Convert to string to ensure compatibility
}
if include_vector_data and "vector_data" in entity_info:
entity_row["vector_data"] = str(entity_info["vector_data"])
entities_data.append(entity_row)
# --- Relations ---
for src_entity in all_entities:
for tgt_entity in all_entities:
if src_entity == tgt_entity:
continue
edge_exists = await chunk_entity_relation_graph.has_edge(
src_entity, tgt_entity
)
if edge_exists:
# Get edge information from graph
edge_data = await chunk_entity_relation_graph.get_edge(
src_entity, tgt_entity
)
source_id = edge_data.get("source_id") if edge_data else None
relation_info = {
"graph_data": edge_data,
"source_id": source_id,
}
# Optional: Get vector database information
if include_vector_data:
rel_id = compute_mdhash_id(src_entity + tgt_entity, prefix="rel-")
vector_data = await relationships_vdb.get_by_id(rel_id)
relation_info["vector_data"] = vector_data
relation_row = {
"src_entity": src_entity,
"tgt_entity": tgt_entity,
"source_id": relation_info["source_id"],
"graph_data": str(relation_info["graph_data"]), # Convert to string
}
if include_vector_data and "vector_data" in relation_info:
relation_row["vector_data"] = str(relation_info["vector_data"])
relations_data.append(relation_row)
# --- Relationships (from VectorDB) ---
all_relationships = await relationships_vdb.client_storage
for rel in all_relationships["data"]:
relationships_data.append(
{
"relationship_id": rel["__id__"],
"data": str(rel), # Convert to string for compatibility
}
)
# Export based on format
if file_format == "csv":
# CSV export
with open(output_path, "w", newline="", encoding="utf-8") as csvfile:
# Entities
if entities_data:
csvfile.write("# ENTITIES\n")
writer = csv.DictWriter(csvfile, fieldnames=entities_data[0].keys())
writer.writeheader()
writer.writerows(entities_data)
csvfile.write("\n\n")
# Relations
if relations_data:
csvfile.write("# RELATIONS\n")
writer = csv.DictWriter(csvfile, fieldnames=relations_data[0].keys())
writer.writeheader()
writer.writerows(relations_data)
csvfile.write("\n\n")
# Relationships
if relationships_data:
csvfile.write("# RELATIONSHIPS\n")
writer = csv.DictWriter(
csvfile, fieldnames=relationships_data[0].keys()
)
writer.writeheader()
writer.writerows(relationships_data)
elif file_format == "excel":
# Excel export
import pandas as pd
entities_df = pd.DataFrame(entities_data) if entities_data else pd.DataFrame()
relations_df = (
pd.DataFrame(relations_data) if relations_data else pd.DataFrame()
)
relationships_df = (
pd.DataFrame(relationships_data) if relationships_data else pd.DataFrame()
)
with pd.ExcelWriter(output_path, engine="xlsxwriter") as writer:
if not entities_df.empty:
entities_df.to_excel(writer, sheet_name="Entities", index=False)
if not relations_df.empty:
relations_df.to_excel(writer, sheet_name="Relations", index=False)
if not relationships_df.empty:
relationships_df.to_excel(
writer, sheet_name="Relationships", index=False
)
elif file_format == "md":
# Markdown export
with open(output_path, "w", encoding="utf-8") as mdfile:
mdfile.write("# LightRAG Data Export\n\n")
# Entities
mdfile.write("## Entities\n\n")
if entities_data:
# Write header
mdfile.write("| " + " | ".join(entities_data[0].keys()) + " |\n")
mdfile.write(
"| " + " | ".join(["---"] * len(entities_data[0].keys())) + " |\n"
)
# Write rows
for entity in entities_data:
mdfile.write(
"| " + " | ".join(str(v) for v in entity.values()) + " |\n"
)
mdfile.write("\n\n")
else:
mdfile.write("*No entity data available*\n\n")
# Relations
mdfile.write("## Relations\n\n")
if relations_data:
# Write header
mdfile.write("| " + " | ".join(relations_data[0].keys()) + " |\n")
mdfile.write(
"| " + " | ".join(["---"] * len(relations_data[0].keys())) + " |\n"
)
# Write rows
for relation in relations_data:
mdfile.write(
"| " + " | ".join(str(v) for v in relation.values()) + " |\n"
)
mdfile.write("\n\n")
else:
mdfile.write("*No relation data available*\n\n")
# Relationships
mdfile.write("## Relationships\n\n")
if relationships_data:
# Write header
mdfile.write("| " + " | ".join(relationships_data[0].keys()) + " |\n")
mdfile.write(
"| "
+ " | ".join(["---"] * len(relationships_data[0].keys()))
+ " |\n"
)
# Write rows
for relationship in relationships_data:
mdfile.write(
"| "
+ " | ".join(str(v) for v in relationship.values())
+ " |\n"
)
else:
mdfile.write("*No relationship data available*\n\n")
elif file_format == "txt":
# Plain text export
with open(output_path, "w", encoding="utf-8") as txtfile:
txtfile.write("LIGHTRAG DATA EXPORT\n")
txtfile.write("=" * 80 + "\n\n")
# Entities
txtfile.write("ENTITIES\n")
txtfile.write("-" * 80 + "\n")
if entities_data:
# Create fixed width columns
col_widths = {
k: max(len(k), max(len(str(e[k])) for e in entities_data))
for k in entities_data[0]
}
header = " ".join(k.ljust(col_widths[k]) for k in entities_data[0])
txtfile.write(header + "\n")
txtfile.write("-" * len(header) + "\n")
# Write rows
for entity in entities_data:
row = " ".join(
str(v).ljust(col_widths[k]) for k, v in entity.items()
)
txtfile.write(row + "\n")
txtfile.write("\n\n")
else:
txtfile.write("No entity data available\n\n")
# Relations
txtfile.write("RELATIONS\n")
txtfile.write("-" * 80 + "\n")
if relations_data:
# Create fixed width columns
col_widths = {
k: max(len(k), max(len(str(r[k])) for r in relations_data))
for k in relations_data[0]
}
header = " ".join(k.ljust(col_widths[k]) for k in relations_data[0])
txtfile.write(header + "\n")
txtfile.write("-" * len(header) + "\n")
# Write rows
for relation in relations_data:
row = " ".join(
str(v).ljust(col_widths[k]) for k, v in relation.items()
)
txtfile.write(row + "\n")
txtfile.write("\n\n")
else:
txtfile.write("No relation data available\n\n")
# Relationships
txtfile.write("RELATIONSHIPS\n")
txtfile.write("-" * 80 + "\n")
if relationships_data:
# Create fixed width columns
col_widths = {
k: max(len(k), max(len(str(r[k])) for r in relationships_data))
for k in relationships_data[0]
}
header = " ".join(
k.ljust(col_widths[k]) for k in relationships_data[0]
)
txtfile.write(header + "\n")
txtfile.write("-" * len(header) + "\n")
# Write rows
for relationship in relationships_data:
row = " ".join(
str(v).ljust(col_widths[k]) for k, v in relationship.items()
)
txtfile.write(row + "\n")
else:
txtfile.write("No relationship data available\n\n")
else:
raise ValueError(
f"Unsupported file format: {file_format}. "
f"Choose from: csv, excel, md, txt"
)
if file_format is not None:
print(f"Data exported to: {output_path} with format: {file_format}")
else:
print("Data displayed as table format")
def export_data(
chunk_entity_relation_graph,
entities_vdb,
relationships_vdb,
output_path: str,
file_format: str = "csv",
include_vector_data: bool = False,
) -> None:
"""
Synchronously exports all entities, relations, and relationships to various formats.
Args:
chunk_entity_relation_graph: Graph storage instance for entities and relations
entities_vdb: Vector database storage for entities
relationships_vdb: Vector database storage for relationships
output_path: The path to the output file (including extension).
file_format: Output format - "csv", "excel", "md", "txt".
- csv: Comma-separated values file
- excel: Microsoft Excel file with multiple sheets
- md: Markdown tables
- txt: Plain text formatted output
include_vector_data: Whether to include data from the vector database.
"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
aexport_data(
chunk_entity_relation_graph,
entities_vdb,
relationships_vdb,
output_path,
file_format,
include_vector_data,
)
)
def lazy_external_import(module_name: str, class_name: str) -> Callable[..., Any]: def lazy_external_import(module_name: str, class_name: str) -> Callable[..., Any]:
"""Lazily import a class from an external module based on the package of the caller.""" """Lazily import a class from an external module based on the package of the caller."""
# Get the caller's module and package # Get the caller's module and package

1066
lightrag/utils_graph.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -507,3 +507,58 @@ export const loginToServer = async (username: string, password: string): Promise
return response.data; 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,117 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
import { updateGraphNode, updateGraphEdge } from '@/utils/graphOperations'
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
import PropertyEditDialog from './PropertyEditDialog'
/**
* Interface for the EditablePropertyRow component props
*/
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
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
}
/**
* EditablePropertyRow component that supports editing property values
* This component is used in the graph properties panel to display and edit entity properties
*/
const EditablePropertyRow = ({
name,
value: initialValue,
onClick,
entityId,
entityType,
sourceId,
targetId,
onValueChange,
isEditable = false
}: EditablePropertyRowProps) => {
const { t } = useTranslation()
const [isEditing, setIsEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [currentValue, setCurrentValue] = useState(initialValue)
useEffect(() => {
setCurrentValue(initialValue)
}, [initialValue])
const handleEditClick = () => {
if (isEditable && !isEditing) {
setIsEditing(true)
}
}
const handleCancel = () => {
setIsEditing(false)
}
const handleSave = async (value: string) => {
if (isSubmitting || value === String(currentValue)) {
setIsEditing(false)
return
}
setIsSubmitting(true)
try {
if (entityType === 'node' && entityId) {
let updatedData = { [name]: value }
if (name === 'entity_id') {
const exists = await checkEntityNameExists(value)
if (exists) {
toast.error(t('graphPanel.propertiesView.errors.duplicateName'))
return
}
updatedData = { 'entity_name': value }
}
await updateEntity(entityId, updatedData, true)
await updateGraphNode(entityId, name, value)
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
} else if (entityType === 'edge' && sourceId && targetId) {
const updatedData = { [name]: value }
await updateRelation(sourceId, targetId, updatedData)
await updateGraphEdge(sourceId, targetId, name, value)
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
}
setIsEditing(false)
setCurrentValue(value)
onValueChange?.(value)
} catch (error) {
console.error('Error updating property:', error)
toast.error(t('graphPanel.propertiesView.errors.updateFailed'))
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex items-center gap-1">
<PropertyName name={name} />
<EditIcon onClick={handleEditClick} />:
<PropertyValue value={currentValue} onClick={onClick} />
<PropertyEditDialog
isOpen={isEditing}
onClose={handleCancel}
onSave={handleSave}
propertyName={name}
initialValue={String(currentValue)}
isSubmitting={isSubmitting}
/>
</div>
)
}
export default EditablePropertyRow

View File

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

View File

@@ -5,6 +5,7 @@ import Button from '@/components/ui/Button'
import useLightragGraph from '@/hooks/useLightragGraph' import useLightragGraph from '@/hooks/useLightragGraph'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { GitBranchPlus, Scissors } from 'lucide-react' import { GitBranchPlus, Scissors } from 'lucide-react'
import EditablePropertyRow from './EditablePropertyRow'
/** /**
* Component that view properties of elements in graph. * Component that view properties of elements in graph.
@@ -169,12 +170,22 @@ const PropertyRow = ({
name, name,
value, value,
onClick, onClick,
tooltip tooltip,
entityId,
entityType,
sourceId,
targetId,
isEditable = false
}: { }: {
name: string name: string
value: any value: any
onClick?: () => void onClick?: () => void
tooltip?: string tooltip?: string
entityId?: string
entityType?: 'node' | 'edge'
sourceId?: string
targetId?: string
isEditable?: boolean
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -184,8 +195,23 @@ const PropertyRow = ({
return translation === translationKey ? name : translation return translation === translationKey ? name : translation
} }
// Since Text component uses a label internally, we'll use a span here instead of a label // Use EditablePropertyRow for editable fields (description, entity_id and keywords)
// to avoid nesting labels which is not recommended for accessibility if (isEditable && (name === 'description' || name === 'entity_id' || name === 'keywords')) {
return (
<EditablePropertyRow
name={name}
value={value}
onClick={onClick}
entityId={entityId}
entityType={entityType}
sourceId={sourceId}
targetId={targetId}
isEditable={true}
/>
)
}
// For non-editable fields, use the regular Text component
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</span>: <span className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</span>:
@@ -253,7 +279,16 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
{Object.keys(node.properties) {Object.keys(node.properties)
.sort() .sort()
.map((name) => { .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> </div>
{node.relationships.length > 0 && ( {node.relationships.length > 0 && (
@@ -309,7 +344,18 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
{Object.keys(edge.properties) {Object.keys(edge.properties)
.sort() .sort()
.map((name) => { .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>
</div> </div>

View File

@@ -0,0 +1,152 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
interface PropertyEditDialogProps {
isOpen: boolean
onClose: () => void
onSave: (value: string) => void
propertyName: string
initialValue: string
isSubmitting?: boolean
}
/**
* Dialog component for editing property values
* Provides a modal with a title, multi-line text input, and save/cancel buttons
*/
const PropertyEditDialog = ({
isOpen,
onClose,
onSave,
propertyName,
initialValue,
isSubmitting = false
}: PropertyEditDialogProps) => {
const { t } = useTranslation()
const [value, setValue] = useState('')
// Initialize value when dialog opens
useEffect(() => {
if (isOpen) {
setValue(initialValue)
}
}, [isOpen, initialValue])
// Get translated property name
const getPropertyNameTranslation = (name: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
const translation = t(translationKey)
return translation === translationKey ? name : translation
}
// Get textarea configuration based on property name
const getTextareaConfig = (propertyName: string) => {
switch (propertyName) {
case 'description':
return {
// No rows attribute for description to allow auto-sizing
className: 'max-h-[50vh] min-h-[10em] resize-y', // Maximum height 70% of viewport, minimum height ~20 lines, allow vertical resizing
style: {
height: '70vh', // Set initial height to 70% of viewport
minHeight: '20em', // Minimum height ~20 lines
resize: 'vertical' as const // Allow vertical resizing, using 'as const' to fix type
}
};
case 'entity_id':
return {
rows: 2,
className: '',
style: {}
};
case 'keywords':
return {
rows: 4,
className: '',
style: {}
};
default:
return {
rows: 5,
className: '',
style: {}
};
}
};
const handleSave = () => {
if (value.trim() !== '') {
onSave(value)
onClose()
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t('graphPanel.propertiesView.editProperty', {
property: getPropertyNameTranslation(propertyName)
})}
</DialogTitle>
<DialogDescription>
{t('graphPanel.propertiesView.editPropertyDescription')}
</DialogDescription>
</DialogHeader>
{/* Multi-line text input using textarea */}
<div className="grid gap-4 py-4">
{(() => {
const config = getTextareaConfig(propertyName);
return propertyName === 'description' ? (
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
style={config.style}
disabled={isSubmitting}
/>
) : (
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
rows={config.rows}
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
disabled={isSubmitting}
/>
);
})()}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
{t('common.cancel')}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSubmitting}
>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default PropertyEditDialog

View File

@@ -0,0 +1,53 @@
import { PencilIcon } from 'lucide-react'
import Text from '@/components/ui/Text'
import { useTranslation } from 'react-i18next'
interface PropertyNameProps {
name: string
}
export const PropertyName = ({ name }: PropertyNameProps) => {
const { t } = useTranslation()
const getPropertyNameTranslation = (propName: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}`
const translation = t(translationKey)
return translation === translationKey ? propName : translation
}
return (
<span className="text-primary/60 tracking-wide whitespace-nowrap">
{getPropertyNameTranslation(name)}
</span>
)
}
interface EditIconProps {
onClick: () => void
}
export const EditIcon = ({ onClick }: EditIconProps) => (
<div>
<PencilIcon
className="h-3 w-3 text-gray-500 hover:text-gray-700 cursor-pointer"
onClick={onClick}
/>
</div>
)
interface PropertyValueProps {
value: any
onClick?: () => void
}
export const PropertyValue = ({ value, onClick }: PropertyValueProps) => (
<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={value}
side="left"
onClick={onClick}
/>
</div>
)

View File

@@ -33,7 +33,8 @@
"guestMode": "وضع بدون تسجيل دخول" "guestMode": "وضع بدون تسجيل دخول"
}, },
"common": { "common": {
"cancel": "إلغاء" "cancel": "إلغاء",
"save": "حفظ"
}, },
"documentPanel": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
@@ -236,6 +237,17 @@
"vectorStorage": "تخزين المتجهات" "vectorStorage": "تخزين المتجهات"
}, },
"propertiesView": { "propertiesView": {
"editProperty": "تعديل {{property}}",
"editPropertyDescription": "قم بتحرير قيمة الخاصية في منطقة النص أدناه.",
"errors": {
"duplicateName": "اسم العقدة موجود بالفعل",
"updateFailed": "فشل تحديث العقدة",
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا"
},
"success": {
"entityUpdated": "تم تحديث العقدة بنجاح",
"relationUpdated": "تم تحديث العلاقة بنجاح"
},
"node": { "node": {
"title": "عقدة", "title": "عقدة",
"id": "المعرف", "id": "المعرف",

View File

@@ -33,7 +33,8 @@
"guestMode": "Login Free" "guestMode": "Login Free"
}, },
"common": { "common": {
"cancel": "Cancel" "cancel": "Cancel",
"save": "Save"
}, },
"documentPanel": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
@@ -156,6 +157,7 @@
"animal": "Animal", "animal": "Animal",
"unknown": "Unknown", "unknown": "Unknown",
"object": "Object", "object": "Object",
"group": "Group",
"technology": "Technology" "technology": "Technology"
}, },
"sideBar": { "sideBar": {
@@ -235,6 +237,17 @@
"vectorStorage": "Vector Storage" "vectorStorage": "Vector Storage"
}, },
"propertiesView": { "propertiesView": {
"editProperty": "Edit {{property}}",
"editPropertyDescription": "Edit the property value in the text area below.",
"errors": {
"duplicateName": "Node name already exists",
"updateFailed": "Failed to update node",
"tryAgainLater": "Please try again later"
},
"success": {
"entityUpdated": "Node updated successfully",
"relationUpdated": "Relation updated successfully"
},
"node": { "node": {
"title": "Node", "title": "Node",
"id": "ID", "id": "ID",

View File

@@ -33,7 +33,8 @@
"guestMode": "Mode sans connexion" "guestMode": "Mode sans connexion"
}, },
"common": { "common": {
"cancel": "Annuler" "cancel": "Annuler",
"save": "Sauvegarder"
}, },
"documentPanel": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
@@ -236,6 +237,17 @@
"vectorStorage": "Stockage vectoriel" "vectorStorage": "Stockage vectoriel"
}, },
"propertiesView": { "propertiesView": {
"editProperty": "Modifier {{property}}",
"editPropertyDescription": "Modifiez la valeur de la propriété dans la zone de texte ci-dessous.",
"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"
},
"success": {
"entityUpdated": "Nœud mis à jour avec succès",
"relationUpdated": "Relation mise à jour avec succès"
},
"node": { "node": {
"title": "Nœud", "title": "Nœud",
"id": "ID", "id": "ID",

View File

@@ -33,7 +33,8 @@
"guestMode": "无需登陆" "guestMode": "无需登陆"
}, },
"common": { "common": {
"cancel": "取消" "cancel": "取消",
"save": "保存"
}, },
"documentPanel": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
@@ -236,6 +237,17 @@
"vectorStorage": "向量存储" "vectorStorage": "向量存储"
}, },
"propertiesView": { "propertiesView": {
"editProperty": "编辑{{property}}",
"editPropertyDescription": "在下方文本区域编辑属性值。",
"errors": {
"duplicateName": "节点名称已存在",
"updateFailed": "更新节点失败",
"tryAgainLater": "请稍后重试"
},
"success": {
"entityUpdated": "节点更新成功",
"relationUpdated": "关系更新成功"
},
"node": { "node": {
"title": "节点", "title": "节点",
"id": "ID", "id": "ID",

View File

@@ -33,7 +33,8 @@
"guestMode": "免登入" "guestMode": "免登入"
}, },
"common": { "common": {
"cancel": "取消" "cancel": "取消",
"save": "儲存"
}, },
"documentPanel": { "documentPanel": {
"clearDocuments": { "clearDocuments": {
@@ -236,6 +237,17 @@
"vectorStorage": "向量儲存" "vectorStorage": "向量儲存"
}, },
"propertiesView": { "propertiesView": {
"editProperty": "編輯{{property}}",
"editPropertyDescription": "在下方文字區域編輯屬性值。",
"errors": {
"duplicateName": "節點名稱已存在",
"updateFailed": "更新節點失敗",
"tryAgainLater": "請稍後重試"
},
"success": {
"entityUpdated": "節點更新成功",
"relationUpdated": "關係更新成功"
},
"node": { "node": {
"title": "節點", "title": "節點",
"id": "ID", "id": "ID",
@@ -296,13 +308,14 @@
"parametersTitle": "參數", "parametersTitle": "參數",
"parametersDescription": "設定查詢參數", "parametersDescription": "設定查詢參數",
"queryMode": "查詢模式", "queryMode": "查詢模式",
"queryModeTooltip": "選擇檢索策略:\n• Naive基礎搜尋無進階技術\n• Local上下文相關資訊檢索\n• Global利用全域知識庫\n• Hybrid結合本地和全域檢索\n• Mix整合知識圖譜和向量檢索", "queryModeTooltip": "選擇檢索策略:\n• Naive基礎搜尋無進階技術\n• Local上下文相關資訊檢索\n• Global利用全域知識庫\n• Hybrid結合本地和全域檢索\n• Mix整合知識圖譜和向量檢索\n• Bypass直接傳遞查詢到LLM不進行檢索",
"queryModeOptions": { "queryModeOptions": {
"naive": "Naive", "naive": "Naive",
"local": "Local", "local": "Local",
"global": "Global", "global": "Global",
"hybrid": "Hybrid", "hybrid": "Hybrid",
"mix": "Mix" "mix": "Mix",
"bypass": "Bypass"
}, },
"responseFormat": "回應格式", "responseFormat": "回應格式",
"responseFormatTooltip": "定義回應格式。例如:\n• 多段落\n• 單段落\n• 重點", "responseFormatTooltip": "定義回應格式。例如:\n• 多段落\n• 單段落\n• 重點",

View File

@@ -116,6 +116,10 @@ interface GraphState {
// Node operation state // Node operation state
nodeToExpand: string | null nodeToExpand: string | null
nodeToPrune: string | null nodeToPrune: string | null
// Version counter to trigger data refresh
graphDataVersion: number
incrementGraphDataVersion: () => void
} }
const useGraphStoreBase = create<GraphState>()((set) => ({ const useGraphStoreBase = create<GraphState>()((set) => ({
@@ -219,6 +223,10 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }), triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),
triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }), triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),
// Version counter implementation
graphDataVersion: 0,
incrementGraphDataVersion: () => set((state) => ({ graphDataVersion: state.graphDataVersion + 1 })),
})) }))
const useGraphStore = createSelectors(useGraphStoreBase) const useGraphStore = createSelectors(useGraphStoreBase)

View File

@@ -0,0 +1,175 @@
import { useGraphStore } from '@/stores/graph'
/**
* Interface for tracking edges that need updating when a node ID changes
*/
interface EdgeToUpdate {
originalDynamicId: string
newEdgeId: string
edgeIndex: number
}
/**
* Update node in the graph visualization
* 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
*/
export const updateGraphNode = async (nodeId: string, propertyName: string, newValue: string) => {
// Get graph state from store
const sigmaGraph = useGraphStore.getState().sigmaGraph
const rawGraph = useGraphStore.getState().rawGraph
// Validate graph state
if (!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
*
* @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
*/
export const updateGraphEdge = async (sourceId: string, targetId: string, propertyName: string, newValue: string) => {
// Get graph state from store
const sigmaGraph = useGraphStore.getState().sigmaGraph
const rawGraph = useGraphStore.getState().rawGraph
// Validate graph state
if (!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 if (keyToUse !== null) {
// Fallback: try to find edge by key in edge ID map
const edgeIndexByKey = rawGraph.edgeIdMap[keyToUse]
if (edgeIndexByKey !== undefined && rawGraph.edges[edgeIndexByKey]) {
rawGraph.edges[edgeIndexByKey].properties[propertyName] = newValue
}
}
}
} catch (error) {
console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error)
throw new Error('Failed to update edge in graph')
}
}