diff --git a/lightrag_webui/src/components/graph/EditablePropertyRow.tsx b/lightrag_webui/src/components/graph/EditablePropertyRow.tsx index 5a1297d3..1604bcdc 100644 --- a/lightrag_webui/src/components/graph/EditablePropertyRow.tsx +++ b/lightrag_webui/src/components/graph/EditablePropertyRow.tsx @@ -126,9 +126,10 @@ const EditablePropertyRow = ({ } else { // Node was updated/renamed normally try { + const graphValue = name === 'entity_id' ? finalValue : value await useGraphStore .getState() - .updateNodeAndSelect(nodeId, entityId, name, finalValue) + .updateNodeAndSelect(nodeId, entityId, name, graphValue) } catch (error) { console.error('Error updating node in graph:', error) throw new Error('Failed to update node in graph') diff --git a/lightrag_webui/src/components/graph/PropertyEditDialog.tsx b/lightrag_webui/src/components/graph/PropertyEditDialog.tsx index 001861a6..457b2b48 100644 --- a/lightrag_webui/src/components/graph/PropertyEditDialog.tsx +++ b/lightrag_webui/src/components/graph/PropertyEditDialog.tsx @@ -88,9 +88,10 @@ const PropertyEditDialog = ({ }; const handleSave = async () => { - if (value.trim() !== '') { + const trimmedValue = value.trim() + if (trimmedValue !== '') { const options = propertyName === 'entity_id' ? { allowMerge } : undefined - await onSave(value, options) + await onSave(trimmedValue, options) } } diff --git a/lightrag_webui/src/hooks/useLightragGraph.tsx b/lightrag_webui/src/hooks/useLightragGraph.tsx index a52a7391..7a460dba 100644 --- a/lightrag_webui/src/hooks/useLightragGraph.tsx +++ b/lightrag_webui/src/hooks/useLightragGraph.tsx @@ -10,225 +10,18 @@ import { useBackendState } from '@/stores/state' import { useSettingsStore } from '@/stores/settings' import seedrandom from 'seedrandom' - -const TYPE_SYNONYMS: Record = { - 'unknown': 'unknown', - '未知': 'unknown', - - 'other': 'other', - '其它': 'other', - - 'concept': 'concept', - 'object': 'concept', - 'type': 'concept', - 'category': 'concept', - 'model': 'concept', - 'project': 'concept', - 'condition': 'concept', - 'rule': 'concept', - 'regulation': 'concept', - 'article': 'concept', - 'law': 'concept', - 'legalclause': 'concept', - 'policy': 'concept', - 'disease': 'concept', - '概念': 'concept', - '对象': 'concept', - '类别': 'concept', - '分类': 'concept', - '模型': 'concept', - '项目': 'concept', - '条件': 'concept', - '规则': 'concept', - '法律': 'concept', - '法律条款': 'concept', - '条文': 'concept', - '政策': 'policy', - '疾病': 'concept', - - 'method': 'method', - 'process': 'method', - '方法': 'method', - '过程': 'method', - - 'artifact': 'artifact', - 'technology': 'artifact', - 'tech': 'artifact', - 'product': 'artifact', - 'equipment': 'artifact', - 'device': 'artifact', - 'stuff': 'artifact', - 'component': 'artifact', - 'material': 'artifact', - 'chemical': 'artifact', - 'drug': 'artifact', - 'medicine': 'artifact', - 'food': 'artifact', - 'weapon': 'artifact', - 'arms': 'artifact', - '人工制品': 'artifact', - '人造物品': 'artifact', - '技术': 'technology', - '科技': 'technology', - '产品': 'artifact', - '设备': 'artifact', - '装备': 'artifact', - '物品': 'artifact', - '材料': 'artifact', - '化学': 'artifact', - '药物': 'artifact', - '食品': 'artifact', - '武器': 'artifact', - '军火': 'artifact', - - 'naturalobject': 'naturalobject', - 'natural': 'naturalobject', - 'phenomena': 'naturalobject', - 'substance': 'naturalobject', - 'plant': 'naturalobject', - '自然对象': 'naturalobject', - '自然物体': 'naturalobject', - '自然现象': 'naturalobject', - '物质': 'naturalobject', - '物体': 'naturalobject', - - 'data': 'data', - 'figure': 'data', - 'value': 'data', - '数据': 'data', - '数字': 'data', - '数值': 'data', - - 'content': 'content', - 'book': 'content', - 'video': 'content', - '内容': 'content', - '作品': 'content', - '书籍': 'content', - '视频': 'content', - - 'organization': 'organization', - 'org': 'organization', - 'company': 'organization', - '组织': 'organization', - '公司': 'organization', - '机构': 'organization', - '组织机构': 'organization', - - 'event': 'event', - '事件': 'event', - 'activity': 'event', - '活动': 'event', - - 'person': 'person', - 'people': 'person', - 'human': 'person', - 'role': 'person', - '人物': 'person', - '人类': 'person', - '人': 'person', - '角色': 'person', - - 'creature': 'creature', - 'animal': 'creature', - 'beings': 'creature', - 'being': 'creature', - 'alien': 'creature', - 'ghost': 'creature', - '动物': 'creature', - '生物': 'creature', - '神仙': 'creature', - '鬼怪': 'creature', - '妖怪': 'creature', - - 'location': 'location', - 'geography': 'location', - 'geo': 'location', - 'place': 'location', - 'address': 'location', - '地点': 'location', - '位置': 'location', - '地址': 'location', - '地理': 'location', - '地域': 'location', -}; - -// node type to color mapping -const NODE_TYPE_COLORS: Record = { - 'person': '#4169E1', // RoyalBlue - 'creature': '#bd7ebe', // LightViolet - 'organization': '#00cc00', // LightGreen - 'location': '#cf6d17', // Carrot - 'event': '#00bfa0', // Turquoise - 'concept': '#e3493b', // GoogleRed - 'method': '#b71c1c', // red - 'content': '#0f558a', // NavyBlue - 'data': '#0000ff', // Blue - 'artifact': '#4421af', // DeepPurple - 'naturalobject': '#b2e061', // YellowGreen - 'other': '#f4d371', // Yellow - 'unknown': '#b0b0b0', // Yellow -}; - -// Extended colors pool - Used for unknown node types -const EXTENDED_COLORS = [ - '#84a3e1', // SkyBlue - '#5a2c6d', // DeepViolet - '#2F4F4F', // DarkSlateGray - '#003366', // DarkBlue - '#9b3a31', // DarkBrown - '#00CED1', // DarkTurquoise - '#b300b3', // Purple - '#0f705d', // Green - '#ff99cc', // Pale Pink - '#6ef7b3', // LightGreen - '#cd071e', // ChinaRed -]; +import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor' // Select color based on node type const getNodeColorByType = (nodeType: string | undefined): string => { + const state = useGraphStore.getState() + const { color, map, updated } = resolveNodeColor(nodeType, state.typeColorMap) - const defaultColor = '#5D6D7E'; - - const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown'; - const typeColorMap = useGraphStore.getState().typeColorMap; - - // First try to find standard type - const standardType = TYPE_SYNONYMS[normalizedType]; - - // Check cache using standard type if available, otherwise use normalized type - const cacheKey = standardType || normalizedType; - if (typeColorMap.has(cacheKey)) { - return typeColorMap.get(cacheKey) || defaultColor; + if (updated) { + useGraphStore.setState({ typeColorMap: map }) } - if (standardType) { - const color = NODE_TYPE_COLORS[standardType]; - // Store using standard type name as key - const newMap = new Map(typeColorMap); - newMap.set(standardType, color); - useGraphStore.setState({ typeColorMap: newMap }); - return color; - } - - // For unpredefind nodeTypes, use extended colors - // Find used extended colors - const usedExtendedColors = new Set( - Array.from(typeColorMap.entries()) - .filter(([, color]) => !Object.values(NODE_TYPE_COLORS).includes(color)) - .map(([, color]) => color) - ); - - // Find and use the first unused extended color - const unusedColor = EXTENDED_COLORS.find(color => !usedExtendedColors.has(color)); - const newColor = unusedColor || defaultColor; - - // Update color mapping - use normalized type for unknown types - const newMap = new Map(typeColorMap); - newMap.set(normalizedType, newColor); - useGraphStore.setState({ typeColorMap: newMap }); - - return newColor; + return color || DEFAULT_NODE_COLOR }; diff --git a/lightrag_webui/src/stores/graph.ts b/lightrag_webui/src/stores/graph.ts index 36c1d7b5..156baf14 100644 --- a/lightrag_webui/src/stores/graph.ts +++ b/lightrag_webui/src/stores/graph.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import { createSelectors } from '@/lib/utils' import { DirectedGraph } from 'graphology' import MiniSearch from 'minisearch' +import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor' export type RawNodeType = { // for NetworkX: id is identical to properties['entity_id'] @@ -246,7 +247,7 @@ const useGraphStoreBase = create()((set, get) => ({ console.log('updateNodeAndSelect', nodeId, entityId, propertyName, newValue) - // For entity_id changes (node renaming) with NetworkX graph storage + // For entity_id changes (node renaming) with raw graph storage if ((nodeId === entityId) && (propertyName === 'entity_id')) { // Create new node with updated ID but same attributes sigmaGraph.addNode(newValue, { ...nodeAttributes, label: newValue }) @@ -319,11 +320,21 @@ const useGraphStoreBase = create()((set, get) => ({ // For non-NetworkX nodes or non-entity_id changes const nodeIndex = rawGraph.nodeIdMap[String(nodeId)] if (nodeIndex !== undefined) { - rawGraph.nodes[nodeIndex].properties[propertyName] = newValue + const nodeRef = rawGraph.nodes[nodeIndex] + nodeRef.properties[propertyName] = newValue if (propertyName === 'entity_id') { - rawGraph.nodes[nodeIndex].labels = [newValue] + nodeRef.labels = [newValue] sigmaGraph.setNodeAttribute(String(nodeId), 'label', newValue) } + if (propertyName === 'entity_type') { + const { color, map, updated } = resolveNodeColor(newValue, state.typeColorMap) + const resolvedColor = color || DEFAULT_NODE_COLOR + nodeRef.color = resolvedColor + sigmaGraph.setNodeAttribute(String(nodeId), 'color', resolvedColor) + if (updated) { + set({ typeColorMap: map }) + } + } } // Trigger a re-render by incrementing the version counter diff --git a/lightrag_webui/src/utils/graphColor.ts b/lightrag_webui/src/utils/graphColor.ts new file mode 100644 index 00000000..7f31f612 --- /dev/null +++ b/lightrag_webui/src/utils/graphColor.ts @@ -0,0 +1,228 @@ +const DEFAULT_NODE_COLOR = '#5D6D7E' + +const TYPE_SYNONYMS: Record = { + unknown: 'unknown', + 未知: 'unknown', + + other: 'other', + 其它: 'other', + + concept: 'concept', + object: 'concept', + type: 'concept', + category: 'concept', + model: 'concept', + project: 'concept', + condition: 'concept', + rule: 'concept', + regulation: 'concept', + article: 'concept', + law: 'concept', + legalclause: 'concept', + policy: 'concept', + disease: 'concept', + 概念: 'concept', + 对象: 'concept', + 类别: 'concept', + 分类: 'concept', + 模型: 'concept', + 项目: 'concept', + 条件: 'concept', + 规则: 'concept', + 法律: 'concept', + 法律条款: 'concept', + 条文: 'concept', + 政策: 'policy', + 疾病: 'concept', + + method: 'method', + process: 'method', + 方法: 'method', + 过程: 'method', + + artifact: 'artifact', + technology: 'artifact', + tech: 'artifact', + product: 'artifact', + equipment: 'artifact', + device: 'artifact', + stuff: 'artifact', + component: 'artifact', + material: 'artifact', + chemical: 'artifact', + drug: 'artifact', + medicine: 'artifact', + food: 'artifact', + weapon: 'artifact', + arms: 'artifact', + 人工制品: 'artifact', + 人造物品: 'artifact', + 技术: 'technology', + 科技: 'technology', + 产品: 'artifact', + 设备: 'artifact', + 装备: 'artifact', + 物品: 'artifact', + 材料: 'artifact', + 化学: 'artifact', + 药物: 'artifact', + 食品: 'artifact', + 武器: 'artifact', + 军火: 'artifact', + + naturalobject: 'naturalobject', + natural: 'naturalobject', + phenomena: 'naturalobject', + substance: 'naturalobject', + plant: 'naturalobject', + 自然对象: 'naturalobject', + 自然物体: 'naturalobject', + 自然现象: 'naturalobject', + 物质: 'naturalobject', + 物体: 'naturalobject', + + data: 'data', + figure: 'data', + value: 'data', + 数据: 'data', + 数字: 'data', + 数值: 'data', + + content: 'content', + book: 'content', + video: 'content', + 内容: 'content', + 作品: 'content', + 书籍: 'content', + 视频: 'content', + + organization: 'organization', + org: 'organization', + company: 'organization', + 组织: 'organization', + 公司: 'organization', + 机构: 'organization', + 组织机构: 'organization', + + event: 'event', + 事件: 'event', + activity: 'event', + 活动: 'event', + + person: 'person', + people: 'person', + human: 'person', + role: 'person', + 人物: 'person', + 人类: 'person', + 人: 'person', + 角色: 'person', + + creature: 'creature', + animal: 'creature', + beings: 'creature', + being: 'creature', + alien: 'creature', + ghost: 'creature', + 动物: 'creature', + 生物: 'creature', + 神仙: 'creature', + 鬼怪: 'creature', + 妖怪: 'creature', + + location: 'location', + geography: 'location', + geo: 'location', + place: 'location', + address: 'location', + 地点: 'location', + 位置: 'location', + 地址: 'location', + 地理: 'location', + 地域: 'location' +} + +const NODE_TYPE_COLORS: Record = { + person: '#4169E1', + creature: '#bd7ebe', + organization: '#00cc00', + location: '#cf6d17', + event: '#00bfa0', + concept: '#e3493b', + method: '#b71c1c', + content: '#0f558a', + data: '#0000ff', + artifact: '#4421af', + naturalobject: '#b2e061', + other: '#f4d371', + unknown: '#b0b0b0' +} + +const EXTENDED_COLORS = [ + '#84a3e1', + '#5a2c6d', + '#2F4F4F', + '#003366', + '#9b3a31', + '#00CED1', + '#b300b3', + '#0f705d', + '#ff99cc', + '#6ef7b3', + '#cd071e' +] + +const PREDEFINED_COLOR_SET = new Set(Object.values(NODE_TYPE_COLORS)) + +interface ResolveNodeColorResult { + color: string + map: Map + updated: boolean +} + +export const resolveNodeColor = ( + nodeType: string | undefined, + currentMap: Map | undefined +): ResolveNodeColorResult => { + const typeColorMap = currentMap ?? new Map() + const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown' + const standardType = TYPE_SYNONYMS[normalizedType] + const cacheKey = standardType || normalizedType + + if (typeColorMap.has(cacheKey)) { + return { + color: typeColorMap.get(cacheKey) || DEFAULT_NODE_COLOR, + map: typeColorMap, + updated: false + } + } + + if (standardType) { + const color = NODE_TYPE_COLORS[standardType] || DEFAULT_NODE_COLOR + const newMap = new Map(typeColorMap) + newMap.set(standardType, color) + return { + color, + map: newMap, + updated: true + } + } + + const usedExtendedColors = new Set( + Array.from(typeColorMap.values()).filter((color) => !PREDEFINED_COLOR_SET.has(color)) + ) + + const unusedColor = EXTENDED_COLORS.find((color) => !usedExtendedColors.has(color)) + const color = unusedColor || DEFAULT_NODE_COLOR + + const newMap = new Map(typeColorMap) + newMap.set(normalizedType, color) + + return { + color, + map: newMap, + updated: true + } +} + +export { DEFAULT_NODE_COLOR }