Merge branch 'main' into graph-storage-batch-query

This commit is contained in:
yangdx
2025-04-14 13:35:33 +08:00
25 changed files with 2942 additions and 1430 deletions

View File

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

View File

@@ -2,14 +2,29 @@
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
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
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)
@@ -21,7 +36,14 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
Returns:
List[str]: List of graph labels
"""
return await rag.get_graph_labels()
try:
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)])
async def get_knowledge_graph(
@@ -43,10 +65,109 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
Returns:
Dict[str, List[str]]: Knowledge graph for label
"""
return await rag.get_knowledge_graph(
node_label=label,
max_depth=max_depth,
max_nodes=max_nodes,
)
try:
return await rag.get_knowledge_graph(
node_label=label,
max_depth=max_depth,
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

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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title>
<script type="module" crossorigin src="/webui/assets/index-CkwV8nfm.js"></script>
<link rel="stylesheet" crossorigin href="/webui/assets/index-CTB4Vp_z.css">
<script type="module" crossorigin src="/webui/assets/index-CIRM3gxn.js"></script>
<link rel="stylesheet" crossorigin href="/webui/assets/index-BJDb04H1.css">
</head>
<body>
<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
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]:
"""Lazily import a class from an external module based on the package of the caller."""
# 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;
}
/**
* 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

@@ -22,7 +22,7 @@ export default function AppSettings({ className }: AppSettingsProps) {
const setTheme = useSettingsStore.use.setTheme()
const handleLanguageChange = useCallback((value: string) => {
setLanguage(value as 'en' | 'zh' | 'fr' | 'ar')
setLanguage(value as 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW')
}, [setLanguage])
const handleThemeChange = useCallback((value: string) => {
@@ -49,6 +49,7 @@ export default function AppSettings({ className }: AppSettingsProps) {
<SelectItem value="zh"></SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="ar">العربية</SelectItem>
<SelectItem value="zh_TW"></SelectItem>
</SelectContent>
</Select>
</div>

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

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,23 @@ 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}
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 +279,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 +344,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

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

@@ -6,6 +6,7 @@ import en from './locales/en.json'
import zh from './locales/zh.json'
import fr from './locales/fr.json'
import ar from './locales/ar.json'
import zh_TW from './locales/zh_TW.json'
const getStoredLanguage = () => {
try {
@@ -27,7 +28,8 @@ i18n
en: { translation: en },
zh: { translation: zh },
fr: { translation: fr },
ar: { translation: ar }
ar: { translation: ar },
zh_TW: { translation: zh_TW }
},
lng: getStoredLanguage(), // Use stored language settings
fallbackLng: 'en',

View File

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

View File

@@ -33,7 +33,8 @@
"guestMode": "Login Free"
},
"common": {
"cancel": "Cancel"
"cancel": "Cancel",
"save": "Save"
},
"documentPanel": {
"clearDocuments": {
@@ -156,6 +157,7 @@
"animal": "Animal",
"unknown": "Unknown",
"object": "Object",
"group": "Group",
"technology": "Technology"
},
"sideBar": {
@@ -235,6 +237,17 @@
"vectorStorage": "Vector Storage"
},
"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": {
"title": "Node",
"id": "ID",

View File

@@ -33,7 +33,8 @@
"guestMode": "Mode sans connexion"
},
"common": {
"cancel": "Annuler"
"cancel": "Annuler",
"save": "Sauvegarder"
},
"documentPanel": {
"clearDocuments": {
@@ -236,6 +237,17 @@
"vectorStorage": "Stockage vectoriel"
},
"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": {
"title": "Nœud",
"id": "ID",

View File

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

View File

@@ -0,0 +1,361 @@
{
"settings": {
"language": "語言",
"theme": "主題",
"light": "淺色",
"dark": "深色",
"system": "系統"
},
"header": {
"documents": "文件",
"knowledgeGraph": "知識圖譜",
"retrieval": "檢索",
"api": "API",
"projectRepository": "專案庫",
"logout": "登出",
"themeToggle": {
"switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題"
}
},
"login": {
"description": "請輸入您的帳號和密碼登入系統",
"username": "帳號",
"usernamePlaceholder": "請輸入帳號",
"password": "密碼",
"passwordPlaceholder": "請輸入密碼",
"loginButton": "登入",
"loggingIn": "登入中...",
"successMessage": "登入成功",
"errorEmptyFields": "請輸入您的帳號和密碼",
"errorInvalidCredentials": "登入失敗,請檢查帳號和密碼",
"authDisabled": "認證已停用,使用免登入模式",
"guestMode": "免登入"
},
"common": {
"cancel": "取消",
"save": "儲存"
},
"documentPanel": {
"clearDocuments": {
"button": "清空",
"tooltip": "清空文件",
"title": "清空文件",
"description": "此操作將從系統中移除所有文件",
"warning": "警告:此操作將永久刪除所有文件,無法復原!",
"confirm": "確定要清空所有文件嗎?",
"confirmPrompt": "請輸入 yes 確認操作",
"confirmPlaceholder": "輸入 yes 以確認",
"clearCache": "清空 LLM 快取",
"confirmButton": "確定",
"success": "文件清空成功",
"cacheCleared": "快取清空成功",
"cacheClearFailed": "清空快取失敗:\n{{error}}",
"failed": "清空文件失敗:\n{{message}}",
"error": "清空文件失敗:\n{{error}}"
},
"uploadDocuments": {
"button": "上傳",
"tooltip": "上傳文件",
"title": "上傳文件",
"description": "拖曳檔案至此處或點擊瀏覽",
"single": {
"uploading": "正在上傳 {{name}}{{percent}}%",
"success": "上傳成功:\n{{name}} 上傳完成",
"failed": "上傳失敗:\n{{name}}\n{{message}}",
"error": "上傳失敗:\n{{name}}\n{{error}}"
},
"batch": {
"uploading": "正在上傳檔案...",
"success": "檔案上傳完成",
"error": "部分檔案上傳失敗"
},
"generalError": "上傳失敗\n{{error}}",
"fileTypes": "支援的檔案類型TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
"fileUploader": {
"singleFileLimit": "一次只能上傳一個檔案",
"maxFilesLimit": "最多只能上傳 {{count}} 個檔案",
"fileRejected": "檔案 {{name}} 被拒絕",
"unsupportedType": "不支援的檔案類型",
"fileTooLarge": "檔案過大,最大允許 {{maxSize}}",
"dropHere": "將檔案拖放至此處",
"dragAndDrop": "拖放檔案至此處,或點擊選擇檔案",
"removeFile": "移除檔案",
"uploadDescription": "您可以上傳{{isMultiple ? '多個' : count}}個檔案(每個檔案最大{{maxSize}}",
"duplicateFile": "檔案名稱與伺服器上的快取重複"
}
},
"documentManager": {
"title": "文件管理",
"scanButton": "掃描",
"scanTooltip": "掃描輸入目錄中的文件",
"pipelineStatusButton": "pipeline 狀態",
"pipelineStatusTooltip": "查看pipeline 狀態",
"uploadedTitle": "已上傳文件",
"uploadedDescription": "已上傳文件清單及其狀態",
"emptyTitle": "無文件",
"emptyDescription": "尚未上傳任何文件",
"columns": {
"id": "ID",
"summary": "摘要",
"status": "狀態",
"length": "長度",
"chunks": "分塊",
"created": "建立時間",
"updated": "更新時間",
"metadata": "元資料"
},
"status": {
"all": "全部",
"completed": "已完成",
"processing": "處理中",
"pending": "等待中",
"failed": "失敗"
},
"errors": {
"loadFailed": "載入文件失敗\n{{error}}",
"scanFailed": "掃描文件失敗\n{{error}}",
"scanProgressFailed": "取得掃描進度失敗\n{{error}}"
},
"fileNameLabel": "檔案名稱",
"showButton": "顯示",
"hideButton": "隱藏",
"showFileNameTooltip": "顯示檔案名稱",
"hideFileNameTooltip": "隱藏檔案名稱"
},
"pipelineStatus": {
"title": "pipeline 狀態",
"busy": "pipeline 忙碌中",
"requestPending": "待處理請求",
"jobName": "工作名稱",
"startTime": "開始時間",
"progress": "進度",
"unit": "梯次",
"latestMessage": "最新訊息",
"historyMessages": "歷史訊息",
"errors": {
"fetchFailed": "取得pipeline 狀態失敗\n{{error}}"
}
}
},
"graphPanel": {
"dataIsTruncated": "圖資料已截斷至最大回傳節點數",
"statusDialog": {
"title": "LightRAG 伺服器設定",
"description": "查看目前系統狀態和連線資訊"
},
"legend": "圖例",
"nodeTypes": {
"person": "人物角色",
"category": "分類",
"geo": "地理名稱",
"location": "位置",
"organization": "組織機構",
"event": "事件",
"equipment": "設備",
"weapon": "武器",
"animal": "動物",
"unknown": "未知",
"object": "物品",
"group": "群組",
"technology": "技術"
},
"sideBar": {
"settings": {
"settings": "設定",
"healthCheck": "健康檢查",
"showPropertyPanel": "顯示屬性面板",
"showSearchBar": "顯示搜尋列",
"showNodeLabel": "顯示節點標籤",
"nodeDraggable": "節點可拖曳",
"showEdgeLabel": "顯示 Edge 標籤",
"hideUnselectedEdges": "隱藏未選取的 Edge",
"edgeEvents": "Edge 事件",
"maxQueryDepth": "最大查詢深度",
"maxNodes": "最大回傳節點數",
"maxLayoutIterations": "最大版面配置迭代次數",
"resetToDefault": "重設為預設值",
"edgeSizeRange": "Edge 粗細範圍",
"depth": "深度",
"max": "最大值",
"degree": "鄰邊",
"apiKey": "API key",
"enterYourAPIkey": "輸入您的 API key",
"save": "儲存",
"refreshLayout": "重新整理版面配置"
},
"zoomControl": {
"zoomIn": "放大",
"zoomOut": "縮小",
"resetZoom": "重設縮放",
"rotateCamera": "順時針旋轉圖形",
"rotateCameraCounterClockwise": "逆時針旋轉圖形"
},
"layoutsControl": {
"startAnimation": "繼續版面配置動畫",
"stopAnimation": "停止版面配置動畫",
"layoutGraph": "圖形版面配置",
"layouts": {
"Circular": "環形",
"Circlepack": "圓形打包",
"Random": "隨機",
"Noverlaps": "無重疊",
"Force Directed": "力導向",
"Force Atlas": "力圖"
}
},
"fullScreenControl": {
"fullScreen": "全螢幕",
"windowed": "視窗"
},
"legendControl": {
"toggleLegend": "切換圖例顯示"
}
},
"statusIndicator": {
"connected": "已連線",
"disconnected": "未連線"
},
"statusCard": {
"unavailable": "狀態資訊不可用",
"storageInfo": "儲存資訊",
"workingDirectory": "工作目錄",
"inputDirectory": "輸入目錄",
"llmConfig": "LLM 設定",
"llmBinding": "LLM 綁定",
"llmBindingHost": "LLM 綁定主機",
"llmModel": "LLM 模型",
"maxTokens": "最大權杖數",
"embeddingConfig": "嵌入設定",
"embeddingBinding": "嵌入綁定",
"embeddingBindingHost": "嵌入綁定主機",
"embeddingModel": "嵌入模型",
"storageConfig": "儲存設定",
"kvStorage": "KV 儲存",
"docStatusStorage": "文件狀態儲存",
"graphStorage": "圖形儲存",
"vectorStorage": "向量儲存"
},
"propertiesView": {
"editProperty": "編輯{{property}}",
"editPropertyDescription": "在下方文字區域編輯屬性值。",
"errors": {
"duplicateName": "節點名稱已存在",
"updateFailed": "更新節點失敗",
"tryAgainLater": "請稍後重試"
},
"success": {
"entityUpdated": "節點更新成功",
"relationUpdated": "關係更新成功"
},
"node": {
"title": "節點",
"id": "ID",
"labels": "標籤",
"degree": "度數",
"properties": "屬性",
"relationships": "關係(子圖內)",
"expandNode": "展開節點",
"pruneNode": "修剪節點",
"deleteAllNodesError": "拒絕刪除圖中的所有節點",
"nodesRemoved": "已刪除 {{count}} 個節點,包括孤立節點",
"noNewNodes": "沒有發現可以展開的節點",
"propertyNames": {
"description": "描述",
"entity_id": "名稱",
"entity_type": "類型",
"source_id": "來源ID",
"Neighbour": "鄰接",
"file_path": "來源"
}
},
"edge": {
"title": "關係",
"id": "ID",
"type": "類型",
"source": "來源節點",
"target": "目標節點",
"properties": "屬性"
}
},
"search": {
"placeholder": "搜尋節點...",
"message": "還有 {count} 個"
},
"graphLabels": {
"selectTooltip": "選擇查詢標籤",
"noLabels": "未找到標籤",
"label": "標籤",
"placeholder": "搜尋標籤...",
"andOthers": "還有 {count} 個",
"refreshTooltip": "重新載入圖形資料"
},
"emptyGraph": "圖譜資料為空"
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "複製到剪貼簿",
"copyError": "複製文字到剪貼簿失敗"
},
"retrieval": {
"startPrompt": "輸入查詢開始檢索",
"clear": "清空",
"send": "送出",
"placeholder": "輸入查詢...",
"error": "錯誤:取得回應失敗"
},
"querySettings": {
"parametersTitle": "參數",
"parametersDescription": "設定查詢參數",
"queryMode": "查詢模式",
"queryModeTooltip": "選擇檢索策略:\n• Naive基礎搜尋無進階技術\n• Local上下文相關資訊檢索\n• Global利用全域知識庫\n• Hybrid結合本地和全域檢索\n• Mix整合知識圖譜和向量檢索\n• Bypass直接傳遞查詢到LLM不進行檢索",
"queryModeOptions": {
"naive": "Naive",
"local": "Local",
"global": "Global",
"hybrid": "Hybrid",
"mix": "Mix",
"bypass": "Bypass"
},
"responseFormat": "回應格式",
"responseFormatTooltip": "定義回應格式。例如:\n• 多段落\n• 單段落\n• 重點",
"responseFormatOptions": {
"multipleParagraphs": "多段落",
"singleParagraph": "單段落",
"bulletPoints": "重點"
},
"topK": "Top K結果",
"topKTooltip": "檢索的前幾項結果數。在'local'模式下表示實體,在'global'模式下表示關係",
"topKPlaceholder": "結果數量",
"maxTokensTextUnit": "文字單元最大權杖數",
"maxTokensTextUnitTooltip": "每個檢索文字區塊允許的最大權杖數",
"maxTokensGlobalContext": "全域上下文最大權杖數",
"maxTokensGlobalContextTooltip": "全域檢索中關係描述的最大權杖數",
"maxTokensLocalContext": "本地上下文最大權杖數",
"maxTokensLocalContextTooltip": "本地檢索中實體描述的最大權杖數",
"historyTurns": "歷史輪次",
"historyTurnsTooltip": "回應上下文中考慮的完整對話輪次(使用者-助手對)數量",
"historyTurnsPlaceholder": "歷史輪次數",
"hlKeywords": "進階關鍵字",
"hlKeywordsTooltip": "檢索中優先考慮的進階關鍵字清單。用逗號分隔",
"hlkeywordsPlaceHolder": "輸入關鍵字",
"llKeywords": "基礎關鍵字",
"llKeywordsTooltip": "用於細化檢索重點的基礎關鍵字清單。用逗號分隔",
"onlyNeedContext": "僅需上下文",
"onlyNeedContextTooltip": "如果為True僅回傳檢索到的上下文而不產生回應",
"onlyNeedPrompt": "僅需提示",
"onlyNeedPromptTooltip": "如果為True僅回傳產生的提示而不產生回應",
"streamResponse": "串流回應",
"streamResponseTooltip": "如果為True啟用即時串流輸出回應"
}
},
"apiSite": {
"loading": "正在載入 API 文件..."
},
"apiKeyAlert": {
"title": "需要 API key",
"description": "請輸入您的 API key 以存取服務",
"placeholder": "請輸入 API key",
"save": "儲存"
}
}

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)

View File

@@ -5,7 +5,7 @@ import { defaultQueryLabel } from '@/lib/constants'
import { Message, QueryRequest } from '@/api/lightrag'
type Theme = 'dark' | 'light' | 'system'
type Language = 'en' | 'zh' | 'fr' | 'ar'
type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW'
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'
interface SettingsState {

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')
}
}