feat: 实现统一图谱适配器及前端组件重构

重构图谱系统架构,引入适配器模式支持多种图数据库类型。主要变更包括:

1. 新增 GraphAdapter 基类及 LightRAG/Upload 适配器实现
2. 重构前端组件结构,新增 GraphDetailPanel 等可复用组件
3. 实现 useGraph 组合式函数统一管理图谱状态
4. 优化 Neo4j 节点/边数据标准化处理
5. 新增统一图谱 API 接口及对应测试用例
6. 改进前端交互体验,增加节点详情展示等功能

前端组件库重构为模块化结构,提升代码复用性。适配器模式使系统可灵活支持不同图数据源,为后续扩展奠定基础。

Next:上传图谱文件的时候,支持属性解析
This commit is contained in:
Wenjie Zhang 2025-12-15 23:25:56 +08:00
parent 225ccce69c
commit 3d63418adf
18 changed files with 1487 additions and 1976 deletions

View File

@ -5,13 +5,215 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query
from src.storage.db.models import User
from server.utils.auth_middleware import get_admin_user
from src import graph_base, knowledge_base
from src.knowledge.adapters.factory import GraphAdapterFactory
from src.knowledge.adapters.base import GraphAdapter
from src.utils.logging_config import logger
graph = APIRouter(prefix="/graph", tags=["graph"])
# =============================================================================
# === 子图查询分组 ===
# === 统一图谱接口 (Unified Graph API) ===
# =============================================================================
async def _get_graph_adapter(db_id: str) -> GraphAdapter:
"""
根据数据库ID获取对应的图谱适配器
Args:
db_id: 数据库ID
Returns:
GraphAdapter: 对应的图谱适配器实例
"""
# 1. 检查是否是 LightRAG 数据库
if knowledge_base.is_lightrag_database(db_id):
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail=f"LightRAG database {db_id} not found or inaccessible")
return GraphAdapterFactory.create_adapter("lightrag", lightrag_instance=rag_instance)
# 2. 默认为 Upload/Neo4j 数据库 (假设 db_id 为 "neo4j" 或其他 Neo4j 数据库名)
# 这里我们假设非 LightRAG 的 ID 都是 Neo4j 的数据库名
# 如果未来有更多类型,需要更完善的 ID 区分机制 (例如前缀)
if not graph_base.is_running():
raise HTTPException(status_code=503, detail="Graph database service is not running")
return GraphAdapterFactory.create_adapter("upload", graph_db_instance=graph_base, config={"kgdb_name": db_id})
@graph.get("/list")
async def get_graphs(current_user: User = Depends(get_admin_user)):
"""
获取所有可用的知识图谱列表
Returns:
包含所有图谱信息的列表 (包括 Neo4j LightRAG)
"""
try:
graphs = []
# 1. 获取默认 Neo4j 图谱信息
neo4j_info = graph_base.get_graph_info()
if neo4j_info:
graphs.append({
"id": "neo4j",
"name": "默认图谱",
"type": "neo4j",
"description": "Default graph database for uploaded documents",
"status": neo4j_info.get("status", "unknown"),
"created_at": neo4j_info.get("last_updated"),
"node_count": neo4j_info.get("entity_count", 0),
"edge_count": neo4j_info.get("relationship_count", 0)
})
# 2. 获取 LightRAG 数据库信息
lightrag_dbs = knowledge_base.get_lightrag_databases()
for db in lightrag_dbs:
graphs.append({
"id": db.get("db_id"),
"name": db.get("name"),
"type": "lightrag",
"description": db.get("description"),
"status": "active", # LightRAG DBs are usually active if listed
"created_at": db.get("created_at"),
"metadata": db
})
return {"success": True, "data": graphs}
except Exception as e:
logger.error(f"Failed to list graphs: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Failed to list graphs: {str(e)}")
@graph.get("/subgraph")
async def get_subgraph(
db_id: str = Query(..., description="知识图谱ID"),
node_label: str = Query("*", description="节点标签或查询关键词"),
max_depth: int = Query(2, description="最大深度", ge=1, le=5),
max_nodes: int = Query(100, description="最大节点数", ge=1, le=1000),
current_user: User = Depends(get_admin_user),
):
"""
统一的子图查询接口
Args:
db_id: 图谱ID (LightRAG DB ID "neo4j")
node_label: 查询关键词或标签
max_depth: 扩展深度
max_nodes: 返回最大节点数
"""
try:
logger.info(f"Querying subgraph - db_id: {db_id}, label: {node_label}")
adapter = await _get_graph_adapter(db_id)
# 统一查询参数
# 对于 UploadGraphAdapter, kgdb_name 通常通过 kwargs 传递
# 对于 LightRAGGraphAdapter, max_depth/max_nodes 通过 kwargs 传递
result_data = await adapter.query_nodes(
keyword=node_label,
max_depth=max_depth,
max_nodes=max_nodes,
kgdb_name=db_id if not knowledge_base.is_lightrag_database(db_id) else "neo4j"
)
return {
"success": True,
"data": result_data,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get subgraph: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Failed to get subgraph: {str(e)}")
@graph.get("/labels")
async def get_graph_labels(
db_id: str = Query(..., description="知识图谱ID"),
current_user: User = Depends(get_admin_user)
):
"""
获取图谱的所有标签
"""
try:
adapter = await _get_graph_adapter(db_id)
labels = await adapter.get_labels()
return {"success": True, "data": {"labels": labels}}
except Exception as e:
logger.error(f"Failed to get labels: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get labels: {str(e)}")
@graph.get("/stats")
async def get_graph_stats(
db_id: str = Query(..., description="知识图谱ID"),
current_user: User = Depends(get_admin_user)
):
"""
获取图谱统计信息
"""
try:
if knowledge_base.is_lightrag_database(db_id):
# 复用原有的 LightRAG 统计逻辑
# 这里暂时直接调用原有逻辑,理想情况下也应该封装进 Adapter
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail="Database not found")
knowledge_graph = await rag_instance.get_knowledge_graph(node_label="*", max_depth=1, max_nodes=10000)
entity_types = {}
for node in knowledge_graph.nodes:
entity_type = node.properties.get("entity_type", "unknown")
entity_types[entity_type] = entity_types.get(entity_type, 0) + 1
entity_types_list = [
{"type": k, "count": v}
for k, v in sorted(entity_types.items(), key=lambda x: x[1], reverse=True)
]
return {
"success": True,
"data": {
"total_nodes": len(knowledge_graph.nodes),
"total_edges": len(knowledge_graph.edges),
"entity_types": entity_types_list
}
}
else:
# Neo4j stats
info = graph_base.get_graph_info(graph_name=db_id)
if not info:
raise HTTPException(status_code=404, detail="Graph info not found")
return {
"success": True,
"data": {
"total_nodes": info.get("entity_count", 0),
"total_edges": info.get("relationship_count", 0),
# Neo4j info currently returns 'labels' list, not counts per label.
# Improving this would require updating GraphDatabase.get_graph_info
"entity_types": [
{"type": label, "count": "N/A"}
for label in info.get("labels", [])
]
}
}
except Exception as e:
logger.error(f"Failed to get stats: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
# =============================================================================
# === 兼容性接口 (Deprecated/Compatibility) ===
# =============================================================================
@ -23,147 +225,40 @@ async def get_lightrag_subgraph(
max_nodes: int = Query(100, description="最大节点数", ge=1, le=1000),
current_user: User = Depends(get_admin_user),
):
"""
使用 LightRAG 原生方法获取知识图谱子图
Args:
db_id: LightRAG 数据库实例ID
node_label: 节点标签用于查找起始节点使用 "*" 获取全图
max_depth: 子图的最大深度
max_nodes: 返回的最大节点数量
Returns:
包含节点和边的知识图谱数据
"""
try:
logger.info(
f"获取子图数据 - db_id: {db_id}, node_label: {node_label}, max_depth: {max_depth}, max_nodes: {max_nodes}"
)
# 检查是否是 LightRAG 数据库
if not knowledge_base.is_lightrag_database(db_id):
raise HTTPException(
status_code=400, detail=f"数据库 {db_id} 不是 LightRAG 类型,图谱功能仅支持 LightRAG 知识库"
)
# 获取 LightRAG 实例
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail=f"LightRAG 数据库 {db_id} 不存在或无法访问")
# 使用 LightRAG 的原生 get_knowledge_graph 方法
knowledge_graph = await rag_instance.get_knowledge_graph(
node_label=node_label, max_depth=max_depth, max_nodes=max_nodes
)
# 将 LightRAG 的 KnowledgeGraph 格式转换为前端需要的格式
nodes = []
for node in knowledge_graph.nodes:
nodes.append(
{
"id": node.id,
"labels": node.labels,
"entity_type": node.properties.get("entity_type", "unknown"),
"properties": node.properties,
}
)
edges = []
for edge in knowledge_graph.edges:
edges.append(
{
"id": edge.id,
"source": edge.source,
"target": edge.target,
"type": edge.type,
"properties": edge.properties,
}
)
result = {
"success": True,
"data": {
"nodes": nodes,
"edges": edges,
"is_truncated": knowledge_graph.is_truncated,
"total_nodes": len(nodes),
"total_edges": len(edges),
},
}
logger.info(f"成功获取子图 - 节点数: {len(nodes)}, 边数: {len(edges)}")
return result
except HTTPException:
# 重新抛出 HTTP 异常
raise
except Exception as e:
logger.error(f"获取子图数据失败: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取子图数据失败: {str(e)}")
"""(Deprecated) Use /graph/subgraph instead"""
return await get_subgraph(
db_id=db_id,
node_label=node_label,
max_depth=max_depth,
max_nodes=max_nodes,
current_user=current_user
)
@graph.get("/lightrag/databases")
async def get_lightrag_databases(current_user: User = Depends(get_admin_user)):
"""
获取所有可用的 LightRAG 数据库
Returns:
可用的 LightRAG 数据库列表
"""
"""(Deprecated) Use /graph/list instead"""
try:
lightrag_databases = knowledge_base.get_lightrag_databases()
return {"success": True, "data": {"databases": lightrag_databases}}
except Exception as e:
logger.error(f"获取 LightRAG 数据库列表失败: {e}")
raise HTTPException(status_code=500, detail=f"获取 LightRAG 数据库列表失败: {str(e)}")
# =============================================================================
# === 节点管理分组 ===
# =============================================================================
raise HTTPException(status_code=500, detail=str(e))
@graph.get("/lightrag/labels")
async def get_lightrag_labels(
db_id: str = Query(..., description="数据库ID"), current_user: User = Depends(get_admin_user)
):
"""
获取知识图谱中的所有标签
"""(Deprecated) Use /graph/labels instead"""
return await get_graph_labels(db_id=db_id, current_user=current_user)
Args:
db_id: LightRAG 数据库实例ID
Returns:
图谱中所有可用的标签列表
"""
try:
logger.info(f"获取图谱标签 - db_id: {db_id}")
# 检查是否是 LightRAG 数据库
if not knowledge_base.is_lightrag_database(db_id):
raise HTTPException(
status_code=400, detail=f"数据库 {db_id} 不是 LightRAG 类型,图谱功能仅支持 LightRAG 知识库"
)
# 获取 LightRAG 实例
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail=f"LightRAG 数据库 {db_id} 不存在或无法访问")
# 使用 LightRAG 的原生方法获取所有标签
labels = await rag_instance.get_graph_labels()
return {"success": True, "data": {"labels": labels}}
except HTTPException:
# 重新抛出 HTTP 异常
raise
except Exception as e:
logger.error(f"获取图谱标签失败: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取图谱标签失败: {str(e)}")
@graph.get("/lightrag/stats")
async def get_lightrag_stats(
db_id: str = Query(..., description="数据库ID"), current_user: User = Depends(get_admin_user)
):
"""(Deprecated) Use /graph/stats instead"""
return await get_graph_stats(db_id=db_id, current_user=current_user)
@graph.get("/neo4j/nodes")
@ -172,110 +267,19 @@ async def get_neo4j_nodes(
num: int = Query(100, description="节点数量", ge=1, le=1000),
current_user: User = Depends(get_admin_user),
):
"""
获取图谱节点样本数据
"""
try:
logger.debug(f"Get graph nodes in {kgdb_name} with {num} nodes")
if not graph_base.is_running():
raise HTTPException(status_code=400, detail="图数据库未启动")
result = graph_base.get_sample_nodes(kgdb_name, num)
return {"success": True, "result": result, "message": "success"}
except Exception as e:
logger.error(f"获取图节点数据失败: {e}\n{traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取图节点数据失败: {str(e)}")
"""(Deprecated) Use /graph/subgraph instead"""
response = await get_subgraph(db_id=kgdb_name, node_label="*", max_nodes=num, current_user=current_user)
return {"success": True, "result": response["data"], "message": "success"}
@graph.get("/neo4j/node")
async def get_neo4j_node(
entity_name: str = Query(..., description="实体名称"), current_user: User = Depends(get_admin_user)
):
"""
根据实体名称查询图节点
"""
try:
if not graph_base.is_running():
raise HTTPException(status_code=400, detail="图数据库未启动")
result = graph_base.query_node(keyword=entity_name)
return {"success": True, "result": result, "message": "success"}
except Exception as e:
logger.error(f"查询图节点失败: {e}\n{traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"查询图节点失败: {str(e)}")
# =============================================================================
# === 边管理分组 ===
# =============================================================================
# 可以在这里添加边相关的管理功能
# =============================================================================
# === 图谱分析分组 ===
# =============================================================================
@graph.get("/lightrag/stats")
async def get_lightrag_stats(
db_id: str = Query(..., description="数据库ID"), current_user: User = Depends(get_admin_user)
):
"""
获取知识图谱统计信息
"""
try:
logger.info(f"获取图谱统计信息 - db_id: {db_id}")
# 检查是否是 LightRAG 数据库
if not knowledge_base.is_lightrag_database(db_id):
raise HTTPException(
status_code=400, detail=f"数据库 {db_id} 不是 LightRAG 类型,图谱功能仅支持 LightRAG 知识库"
)
# 获取 LightRAG 实例
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail=f"LightRAG 数据库 {db_id} 不存在或无法访问")
# 通过获取全图来统计节点和边的数量
knowledge_graph = await rag_instance.get_knowledge_graph(
node_label="*",
max_depth=1,
max_nodes=10000, # 设置较大值以获取完整统计
)
# 统计实体类型分布
entity_types = {}
for node in knowledge_graph.nodes:
entity_type = node.properties.get("entity_type", "unknown")
entity_types[entity_type] = entity_types.get(entity_type, 0) + 1
entity_types_list = [
{"type": k, "count": v} for k, v in sorted(entity_types.items(), key=lambda x: x[1], reverse=True)
]
return {
"success": True,
"data": {
"total_nodes": len(knowledge_graph.nodes),
"total_edges": len(knowledge_graph.edges),
"entity_types": entity_types_list,
"is_truncated": knowledge_graph.is_truncated,
},
}
except HTTPException:
# 重新抛出 HTTP 异常
raise
except Exception as e:
logger.error(f"获取图谱统计信息失败: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取图谱统计信息失败: {str(e)}")
"""(Deprecated) Use /graph/subgraph instead"""
# neo4j/node uses query_nodes(keyword=entity_name)
response = await get_subgraph(db_id="neo4j", node_label=entity_name, current_user=current_user)
return {"success": True, "result": response["data"], "message": "success"}
@graph.get("/neo4j/info")
@ -298,10 +302,7 @@ async def index_neo4j_entities(data: dict = Body(default={}), current_user: User
if not graph_base.is_running():
raise HTTPException(status_code=400, detail="图数据库未启动")
# 获取参数或使用默认值
kgdb_name = data.get("kgdb_name", "neo4j")
# 调用GraphDatabase的add_embedding_to_nodes方法
count = graph_base.add_embedding_to_nodes(kgdb_name=kgdb_name)
return {

View File

@ -60,7 +60,8 @@ class DynamicToolMiddleware(AgentMiddleware):
logger.warning(f"MCP server '{mcp}' not pre-loaded. Please add it to mcp_servers list.")
logger.info(
f"Dynamic tool selection: {len(enabled_tools)} tools enabled: {[tool.name for tool in enabled_tools]}"
f"Dynamic tool selection: {len(enabled_tools)} tools enabled: {[tool.name for tool in enabled_tools]}, "
f"selected_tools: {selected_tools}, selected_mcps: {selected_mcps}"
)
# 更新 request 中的工具列表

View File

@ -0,0 +1,6 @@
from .base import GraphAdapter
from .factory import GraphAdapterFactory
from .lightrag import LightRAGGraphAdapter
from .upload import UploadGraphAdapter
__all__ = ["GraphAdapter", "UploadGraphAdapter", "LightRAGGraphAdapter", "GraphAdapterFactory"]

View File

@ -0,0 +1,87 @@
from abc import ABC, abstractmethod
from typing import Any
class GraphAdapter(ABC):
"""图谱适配器基类 (Base Graph Adapter)"""
@abstractmethod
async def query_nodes(self, keyword: str, **kwargs) -> dict[str, Any]:
"""查询节点 (Query nodes)"""
pass
@abstractmethod
async def add_entity(self, triples: list[dict], **kwargs) -> bool:
"""添加实体三元组 (Add entity triples)"""
pass
@abstractmethod
async def get_sample_nodes(self, num: int = 50, **kwargs) -> dict[str, list]:
"""获取样本节点 (Get sample nodes)"""
pass
@abstractmethod
def normalize_node(self, raw_node: Any) -> dict[str, Any]:
"""标准化节点格式 (Normalize node format)"""
pass
@abstractmethod
def normalize_edge(self, raw_edge: Any) -> dict[str, Any]:
"""标准化边格式 (Normalize edge format)"""
pass
@abstractmethod
async def get_labels(self) -> list[str]:
"""获取所有标签 (Get all labels)"""
pass
def _create_standard_node(
self,
node_id: str,
name: str,
entity_type: str,
labels: list[str],
properties: dict[str, Any],
source: str,
) -> dict[str, Any]:
"""
Helper to create a standardized node dictionary.
"""
return {
"id": node_id,
"name": name,
"original_id": node_id,
"type": entity_type,
"labels": labels,
"properties": properties,
"normalized": {
"name": name,
"type": entity_type,
"source": source,
},
"graph_type": source,
}
def _create_standard_edge(
self,
edge_id: str,
source_id: str,
target_id: str,
edge_type: str,
properties: dict[str, Any],
direction: str = "directed",
) -> dict[str, Any]:
"""
Helper to create a standardized edge dictionary.
"""
return {
"id": edge_id,
"source_id": source_id,
"target_id": target_id,
"type": edge_type,
"properties": properties,
"normalized": {
"type": edge_type,
"direction": direction,
},
}

View File

@ -0,0 +1,23 @@
from .base import GraphAdapter
from .lightrag import LightRAGGraphAdapter
from .upload import UploadGraphAdapter
class GraphAdapterFactory:
"""图谱适配器工厂 (Graph Adapter Factory)"""
_registry: dict[str, type[GraphAdapter]] = {"upload": UploadGraphAdapter, "lightrag": LightRAGGraphAdapter}
@classmethod
def register(cls, graph_type: str, adapter_class: type[GraphAdapter]):
"""注册适配器类 (Register adapter class)"""
cls._registry[graph_type] = adapter_class
@classmethod
def create_adapter(cls, graph_type: str, **kwargs) -> GraphAdapter:
"""创建适配器实例 (Create adapter instance)"""
adapter_class = cls._registry.get(graph_type)
if not adapter_class:
raise ValueError(f"Unknown graph type: {graph_type}")
return adapter_class(**kwargs)

View File

@ -0,0 +1,154 @@
import logging
from typing import Any
from .base import GraphAdapter
logger = logging.getLogger(__name__)
class LightRAGGraphAdapter(GraphAdapter):
"""LightRAG图谱适配器 (LightRAG Graph Adapter)"""
def __init__(self, lightrag_instance: Any, config: dict[str, Any] = None):
self.config = config or {
"node_label_field": "labels",
"id_field": "id",
"type_field": "entity_type",
"relation_prefix": "HAS_",
}
self.lightrag = lightrag_instance
async def query_nodes(self, keyword: str, **kwargs) -> dict[str, Any]:
# Map keyword to node_label
# If keyword is empty or "*", query all (or sample)
node_label = keyword if keyword and keyword != "*" else "*"
max_depth = kwargs.get("max_depth", 2)
max_nodes = kwargs.get("max_nodes", 100)
# lightrag.get_knowledge_graph is async
# Note: if node_label is "*", LightRAG might return a large graph or sample depending on implementation
raw_graph = await self.lightrag.get_knowledge_graph(
node_label=node_label, max_depth=max_depth, max_nodes=max_nodes
)
return self._convert_lightrag_graph(raw_graph)
async def add_entity(self, triples: list[dict], **kwargs) -> bool:
"""
LightRAG typically builds graph from text.
Direct triple injection might not be supported or requires different API.
"""
logger.warning("add_entity is not fully supported for LightRAG adapter yet.")
return False
async def get_sample_nodes(self, num: int = 50, **kwargs) -> dict[str, list]:
# Use query_nodes with wildcard to get a subgraph
return await self.query_nodes("*", max_nodes=num, **kwargs)
def normalize_node(self, raw_node: Any) -> dict[str, Any]:
# Handle LightRAG Node object
node_id = getattr(raw_node, "id", None)
if node_id is None:
node_id = raw_node.get("id")
labels = getattr(raw_node, "labels", [])
if not labels and hasattr(raw_node, "get"):
labels = raw_node.get("labels", [])
properties = getattr(raw_node, "properties", {})
if not properties and hasattr(raw_node, "get"):
properties = raw_node.get("properties", {})
# 优先使用 entity_id 作为显示名称,因为 Neo4j 中 LightRAG 存储的实体名称在 entity_id 字段
# 如果不存在,则回退到 id
name = properties.get("entity_id", node_id)
# 尝试从 properties 获取 entity_type或者从 labels 中推断(排除 kb_ 前缀的 label
entity_type = properties.get("entity_type", "unknown")
if entity_type == "unknown" and labels:
for label in labels:
if not label.startswith("kb_"):
entity_type = label
break
return self._create_standard_node(
node_id=node_id,
name=name,
entity_type=entity_type,
labels=labels,
properties=properties,
source="lightrag"
)
def normalize_edge(self, raw_edge: Any) -> dict[str, Any]:
# Handle LightRAG Edge object
edge_id = getattr(raw_edge, "id", None)
if edge_id is None:
edge_id = raw_edge.get("id")
source = getattr(raw_edge, "source", None)
if source is None:
source = raw_edge.get("source")
target = getattr(raw_edge, "target", None)
if target is None:
target = raw_edge.get("target")
edge_type = getattr(raw_edge, "type", None)
if edge_type is None:
edge_type = raw_edge.get("type")
properties = getattr(raw_edge, "properties", {})
if not properties and hasattr(raw_edge, "get"):
properties = raw_edge.get("properties", {})
# 优化边的显示类型
# LightRAG 的边类型通常是 "DIRECTED",具体含义在 keywords 或 description 中
display_type = edge_type
if edge_type == "DIRECTED":
keywords = properties.get("keywords", [])
if keywords and isinstance(keywords, list) and len(keywords) > 0:
display_type = keywords[0]
elif properties.get("description"):
# 如果没有 keywords尝试从 description 截取(太长就算了)
desc = properties.get("description", "")
if len(desc) < 20:
display_type = desc
else:
display_type = "related" # fallback
return self._create_standard_edge(
edge_id=edge_id,
source_id=source,
target_id=target,
edge_type=display_type,
properties=properties
)
async def get_labels(self) -> list[str]:
return await self.lightrag.get_graph_labels()
def _convert_lightrag_graph(self, raw_graph) -> dict[str, Any]:
nodes = []
edges = []
# raw_graph has .nodes and .edges lists
if hasattr(raw_graph, "nodes"):
for node in raw_graph.nodes:
nodes.append(self.normalize_node(node))
if hasattr(raw_graph, "edges"):
for edge in raw_graph.edges:
edges.append(self.normalize_edge(edge))
result = {"nodes": nodes, "edges": edges}
# Add metadata if available
if hasattr(raw_graph, "is_truncated"):
result["is_truncated"] = raw_graph.is_truncated
result["total_nodes"] = len(nodes)
result["total_edges"] = len(edges)
return result

View File

@ -0,0 +1,112 @@
from typing import Any
from src.knowledge.graph import GraphDatabase
from .base import GraphAdapter
class UploadGraphAdapter(GraphAdapter):
"""Upload图谱适配器 (Upload Graph Adapter)"""
def __init__(self, graph_db_instance: GraphDatabase, config: dict[str, Any] = None):
self.config = config or {
"node_label": "Entity:Upload",
"id_field": "name",
"relation_label": "RELATION",
"default_tags": ["upload", "user_generated"],
}
self.graph_db = graph_db_instance
async def query_nodes(self, keyword: str, **kwargs) -> dict[str, Any]:
params = self._normalize_query_params(keyword, kwargs)
# 如果关键词是 "*" 或者为空,则执行采样查询
if not params["keyword"] or params["keyword"] == "*":
# 映射 max_nodes 到 num
num = kwargs.get("max_nodes", 100)
raw_results = self.graph_db.get_sample_nodes(
kgdb_name=params.get("kgdb_name", "neo4j"),
num=num
)
else:
# 否则执行关键词搜索
# graph_db.query_node is sync
raw_results = self.graph_db.query_node(
keyword=params["keyword"],
threshold=params.get("threshold", 0.9),
kgdb_name=params.get("kgdb_name", "neo4j"),
hops=params.get("hops", 2),
return_format="graph",
)
return self._format_results(raw_results)
async def add_entity(self, triples: list[dict], **kwargs) -> bool:
kgdb_name = kwargs.get("kgdb_name", "neo4j")
# txt_add_vector_entity is async
await self.graph_db.txt_add_vector_entity(triples, kgdb_name=kgdb_name)
return True
async def get_sample_nodes(self, num: int = 50, **kwargs) -> dict[str, list]:
kgdb_name = kwargs.get("kgdb_name", "neo4j")
# get_sample_nodes is sync
raw_results = self.graph_db.get_sample_nodes(kgdb_name=kgdb_name, num=num)
return self._format_results(raw_results)
def normalize_node(self, raw_node: Any) -> dict[str, Any]:
"""
raw_node expected format: {id: str, name: str, ...}
"""
node_id = raw_node.get("id")
name = raw_node.get("name")
return self._create_standard_node(
node_id=node_id,
name=name,
entity_type="entity",
labels=["Entity", "Upload"],
properties=raw_node,
source="upload"
)
def normalize_edge(self, raw_edge: Any) -> dict[str, Any]:
"""
raw_edge expected format: {type: str, source_id: str, target_id: str, ...}
"""
# Generate an ID if not present (Upload graph edges might not have explicit ID in simple dict return)
edge_id = raw_edge.get("id")
if not edge_id:
edge_id = f"{raw_edge.get('source_id')}_{raw_edge.get('type')}_{raw_edge.get('target_id')}"
return self._create_standard_edge(
edge_id=edge_id,
source_id=raw_edge.get("source_id"),
target_id=raw_edge.get("target_id"),
edge_type=raw_edge.get("type"),
properties=raw_edge
)
async def get_labels(self) -> list[str]:
kgdb_name = self.config.get("kgdb_name", "neo4j")
info = self.graph_db.get_graph_info(graph_name=kgdb_name)
return info.get("labels", []) if info else []
def _normalize_query_params(self, keyword: str, kwargs: dict) -> dict[str, Any]:
# Map max_depth to hops if present
hops = kwargs.get("hops")
if hops is None:
hops = kwargs.get("max_depth", 2)
return {
"keyword": keyword,
"threshold": kwargs.get("threshold", 0.9),
"kgdb_name": kwargs.get("kgdb_name", "neo4j"),
"hops": hops,
"filters": kwargs.get("filters", {}),
"context": kwargs.get("context", {}),
}
def _format_results(self, raw_results: dict[str, list]) -> dict[str, list]:
nodes = [self.normalize_node(n) for n in raw_results.get("nodes", [])]
edges = [self.normalize_edge(e) for e in raw_results.get("edges", [])]
return {"nodes": nodes, "edges": edges}

View File

@ -9,7 +9,7 @@ from neo4j import GraphDatabase as GD
from src import config
from src.models import select_embedding_model
from src.storage.minio.client import get_minio_client, StorageError
from src.storage.minio.client import get_minio_client
from src.utils import logger
from src.utils.datetime_utils import utc_isoformat
@ -106,7 +106,12 @@ class GraphDatabase:
RETURN
{id: elementId(n), name: n.name} AS h,
CASE WHEN rel IS NOT NULL THEN
{type: rel.type, source_id: elementId(n), target_id: elementId(m)}
{
id: elementId(rel),
type: rel.type,
source_id: elementId(startNode(rel)),
target_id: elementId(endNode(rel))
}
ELSE null END AS r,
CASE WHEN m IS NOT NULL THEN
{id: elementId(m), name: m.name}
@ -166,7 +171,12 @@ class GraphDatabase:
WHERE elementId(n) < elementId(m)
RETURN
{id: elementId(n), name: n.name} AS h,
{type: r.type, source_id: elementId(n), target_id: elementId(m)} AS r,
{
id: elementId(r),
type: r.type,
source_id: elementId(startNode(r)),
target_id: elementId(endNode(r))
} AS r,
{id: elementId(m), name: m.name} AS t
LIMIT $num
"""
@ -355,12 +365,12 @@ class GraphDatabase:
temp_file_path = None
try:
if parsed_url.scheme in ('http', 'https'): # 如果是 URL
if parsed_url.scheme in ("http", "https"): # 如果是 URL
logger.info(f"检测到 URL正在从 MinIO 下载文件: {file_path}")
# 从 URL 解析 bucket_name 和 object_name
# URL 格式: http://host:port/bucket_name/object_name
path_parts = parsed_url.path.lstrip('/').split('/', 1)
path_parts = parsed_url.path.lstrip("/").split("/", 1)
if len(path_parts) < 2:
raise ValueError(f"无法解析 MinIO URL: {file_path}")
@ -372,8 +382,10 @@ class GraphDatabase:
file_data = await minio_client.adownload_file(bucket_name, object_name)
# 创建临时文件保存下载的内容
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False, encoding='utf-8') as temp_file:
temp_file.write(file_data.decode('utf-8'))
with tempfile.NamedTemporaryFile(
mode="w", suffix=".jsonl", delete=False, encoding="utf-8"
) as temp_file:
temp_file.write(file_data.decode("utf-8"))
temp_file_path = temp_file.name
logger.info(f"文件已下载到临时路径: {temp_file_path}")
@ -592,22 +604,22 @@ class GraphDatabase:
// 1跳出边
[(n {name: $entity_name})-[r1]->(m1) |
{h: {id: elementId(n), name: n.name},
r: {type: r1.type, source_id: elementId(n), target_id: elementId(m1)},
r: {id: elementId(r1), type: r1.type, source_id: elementId(n), target_id: elementId(m1)},
t: {id: elementId(m1), name: m1.name}}],
// 2跳出边
[(n {name: $entity_name})-[r1]->(m1)-[r2]->(m2) |
{h: {id: elementId(m1), name: m1.name},
r: {type: r2.type, source_id: elementId(m1), target_id: elementId(m2)},
r: {id: elementId(r2), type: r2.type, source_id: elementId(m1), target_id: elementId(m2)},
t: {id: elementId(m2), name: m2.name}}],
// 1跳入边
[(m1)-[r1]->(n {name: $entity_name}) |
{h: {id: elementId(m1), name: m1.name},
r: {type: r1.type, source_id: elementId(m1), target_id: elementId(n)},
r: {id: elementId(r1), type: r1.type, source_id: elementId(m1), target_id: elementId(n)},
t: {id: elementId(n), name: n.name}}],
// 2跳入边
[(m2)-[r2]->(m1)-[r1]->(n {name: $entity_name}) |
{h: {id: elementId(m2), name: m2.name},
r: {type: r2.type, source_id: elementId(m2), target_id: elementId(m1)},
r: {id: elementId(r2), type: r2.type, source_id: elementId(m2), target_id: elementId(m1)},
t: {id: elementId(m1), name: m1.name}}]
] AS all_results
UNWIND all_results AS result_list

View File

@ -319,7 +319,7 @@ def get_embedding_config(embed_info: dict) -> dict:
"model": embed_info["name"],
"api_key": os.getenv(embed_info["api_key"]) or embed_info["api_key"],
"base_url": embed_info["base_url"],
"dimension": embed_info.get("dimension", 1024)
"dimension": embed_info.get("dimension", 1024),
}
logger.debug(f"Embedding config from dict: {config_dict}")
return config_dict

View File

@ -0,0 +1,138 @@
"""
Integration tests for the unified graph router endpoints.
"""
from __future__ import annotations
import pytest
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def test_get_graphs_list(test_client, admin_headers):
"""Test retrieving the list of all graphs."""
response = await test_client.get("/api/graph/list", headers=admin_headers)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
graphs = payload["data"]
assert isinstance(graphs, list)
# Check for Neo4j default graph
neo4j_graph = next((g for g in graphs if g["id"] == "neo4j"), None)
assert neo4j_graph is not None
assert neo4j_graph["type"] == "neo4j"
# Note: LightRAG graphs might be empty if none created, but we check structure
async def test_get_subgraph_neo4j(test_client, admin_headers):
"""Test unified subgraph query for Neo4j."""
# Query with a wildcard or a known node. Using "*" to get a sample.
response = await test_client.get(
"/api/graph/subgraph",
params={"db_id": "neo4j", "node_label": "*", "max_nodes": 10},
headers=admin_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
data = payload["data"]
assert "nodes" in data
assert "edges" in data
assert isinstance(data["nodes"], list)
async def test_get_subgraph_lightrag(test_client, admin_headers, knowledge_database):
"""Test unified subgraph query for LightRAG."""
db_id = knowledge_database["db_id"]
response = await test_client.get(
"/api/graph/subgraph",
params={"db_id": db_id, "node_label": "*", "max_nodes": 10},
headers=admin_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
data = payload["data"]
assert "nodes" in data
assert "edges" in data
async def test_get_stats_neo4j(test_client, admin_headers):
"""Test stats endpoint for Neo4j."""
response = await test_client.get(
"/api/graph/stats",
params={"db_id": "neo4j"},
headers=admin_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
data = payload["data"]
assert "total_nodes" in data
assert "total_edges" in data
assert "entity_types" in data
async def test_get_stats_lightrag(test_client, admin_headers, knowledge_database):
"""Test stats endpoint for LightRAG."""
db_id = knowledge_database["db_id"]
response = await test_client.get(
"/api/graph/stats",
params={"db_id": db_id},
headers=admin_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
data = payload["data"]
assert "total_nodes" in data
assert "total_edges" in data
assert "entity_types" in data
async def test_get_labels_neo4j(test_client, admin_headers):
"""Test labels endpoint for Neo4j."""
response = await test_client.get(
"/api/graph/labels",
params={"db_id": "neo4j"},
headers=admin_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
data = payload["data"]
assert "labels" in data
assert isinstance(data["labels"], list)
async def test_deprecated_neo4j_endpoints(test_client, admin_headers):
"""Verify deprecated endpoints still work and return correct structure."""
# /neo4j/nodes
response = await test_client.get(
"/api/graph/neo4j/nodes",
params={"kgdb_name": "neo4j", "num": 5},
headers=admin_headers
)
assert response.status_code == 200
payload = response.json()
# Check compatibility structure
assert payload["success"] is True
assert "result" in payload
assert payload["message"] == "success"
assert "nodes" in payload["result"]
# /neo4j/node
# This might return empty if "NonExistentEntity" doesn't exist, but structure should be valid
response = await test_client.get(
"/api/graph/neo4j/node",
params={"entity_name": "NonExistentEntity"},
headers=admin_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert "result" in payload
assert payload["message"] == "success"

View File

@ -7,15 +7,23 @@ import { apiGet, apiPost } from './base'
*/
// =============================================================================
// === LightRAG图知识库接口分组 ===
// === 统一图谱接口 (Unified Graph API) ===
// =============================================================================
export const lightragApi = {
export const unifiedApi = {
/**
* 获取LightRAG知识图谱子图数据
* 获取所有可用的知识图谱列表
* @returns {Promise} - 图谱列表
*/
getGraphs: async () => {
return await apiGet('/api/graph/list', {}, true)
},
/**
* 获取子图数据 (统一接口)
* @param {Object} params - 查询参数
* @param {string} params.db_id - LightRAG数据库ID
* @param {string} params.node_label - 节点标签"*"获取全图
* @param {string} params.db_id - 图谱ID
* @param {string} params.node_label - 节点标签/关键词
* @param {number} params.max_depth - 最大深度
* @param {number} params.max_nodes - 最大节点数
* @returns {Promise} - 子图数据
@ -34,37 +42,12 @@ export const lightragApi = {
max_nodes: max_nodes.toString()
})
return await apiGet(`/api/graph/lightrag/subgraph?${queryParams.toString()}`, {}, true)
return await apiGet(`/api/graph/subgraph?${queryParams.toString()}`, {}, true)
},
/**
* 获取所有可用的LightRAG数据库
* @returns {Promise} - LightRAG数据库列表
*/
getDatabases: async () => {
return await apiGet('/api/graph/lightrag/databases', {}, true)
},
/**
* 获取LightRAG图谱标签列表
* @param {string} db_id - LightRAG数据库ID
* @returns {Promise} - 标签列表
*/
getLabels: async (db_id) => {
if (!db_id) {
throw new Error('db_id is required')
}
const queryParams = new URLSearchParams({
db_id: db_id
})
return await apiGet(`/api/graph/lightrag/labels?${queryParams.toString()}`, {}, true)
},
/**
* 获取LightRAG图谱统计信息
* @param {string} db_id - LightRAG数据库ID
* 获取图谱统计信息 (统一接口)
* @param {string} db_id - 图谱ID
* @returns {Promise} - 统计信息
*/
getStats: async (db_id) => {
@ -76,10 +59,28 @@ export const lightragApi = {
db_id: db_id
})
return await apiGet(`/api/graph/lightrag/stats?${queryParams.toString()}`, {}, true)
return await apiGet(`/api/graph/stats?${queryParams.toString()}`, {}, true)
},
/**
* 获取图谱标签列表 (统一接口)
* @param {string} db_id - 图谱ID
* @returns {Promise} - 标签列表
*/
getLabels: async (db_id) => {
if (!db_id) {
throw new Error('db_id is required')
}
const queryParams = new URLSearchParams({
db_id: db_id
})
return await apiGet(`/api/graph/labels?${queryParams.toString()}`, {}, true)
}
}
// =============================================================================
// === Neo4j图数据库接口分组 ===
// =============================================================================
@ -222,11 +223,21 @@ export const getGraphStats = async () => {
return neo4jApi.getInfo()
}
// 保持旧的分组导出,便于批量替换
// 兼容性导出 - 使用统一接口替代旧有的 graphApi
export const graphApi = {
getSubgraph: lightragApi.getSubgraph,
getDatabases: lightragApi.getDatabases,
getLabels: lightragApi.getLabels,
getStats: lightragApi.getStats,
...neo4jApi // 临时兼容
// 使用统一接口替代 LightRAG 接口
getSubgraph: unifiedApi.getSubgraph,
getDatabases: async () => {
// 使用统一接口获取所有图谱,然后过滤出 LightRAG 类型的
const response = await unifiedApi.getGraphs()
if (response.success) {
const lightragDbs = response.data.filter(graph => graph.type === 'lightrag')
return { success: true, data: { databases: lightragDbs } }
}
return response
},
getLabels: unifiedApi.getLabels,
getStats: unifiedApi.getStats,
// 保留 Neo4j 接口
...neo4jApi
}

View File

@ -37,7 +37,7 @@ const props = defineProps({
highlightKeywords: { type: Array, default: () => [] }
})
const emit = defineEmits(['ready', 'data-rendered'])
const emit = defineEmits(['ready', 'data-rendered', 'node-click', 'edge-click', 'canvas-click'])
const container = ref(null)
const rootEl = ref(null)
@ -88,6 +88,7 @@ function formatData() {
data: {
label: n[props.labelField] ?? n.name ?? String(n.id),
degree: degrees.get(String(n.id)) || 0,
original: n //
},
}))
@ -95,7 +96,10 @@ function formatData() {
id: e.id ? String(e.id) : `edge-${idx}`,
source: String(e.source_id),
target: String(e.target_id),
data: { label: e.type ?? '' },
data: {
label: e.type ?? '',
original: e //
},
}))
return { nodes, edges }
@ -185,9 +189,38 @@ function initGraph() {
unselectedState: 'inactive', //
multiple: true,
trigger: ['shift'],
//
disableDefault: false,
}
],
})
//
graphInstance.on('node:click', (evt) => {
const { target } = evt
// ID
const nodeId = target.id
// graph data
// G6 v5 target.id ID
// graphInstance.getNodeData(nodeId)
const nodeData = graphInstance.getNodeData(nodeId)
emit('node-click', nodeData)
})
graphInstance.on('edge:click', (evt) => {
const { target } = evt
const edgeId = target.id
const edgeData = graphInstance.getEdgeData(edgeId)
emit('edge-click', edgeData)
})
graphInstance.on('canvas:click', (evt) => {
//
if (!evt.target) {
emit('canvas-click')
}
})
emit('ready', graphInstance)
}

View File

@ -0,0 +1,167 @@
<template>
<transition name="slide-fade">
<div class="detail-card" v-if="visible">
<a-card :bordered="false" size="small" class="info-card">
<template #title>
<div class="card-title">
<span>{{ title }}</span>
<CloseOutlined @click="$emit('close')" class="close-icon" />
</div>
</template>
<div class="card-content">
<template v-if="item">
<a-descriptions :column="1" size="small" :bordered="true">
<template v-if="type === 'node'">
<a-descriptions-item label="名称">{{ item.data?.label }}</a-descriptions-item>
<a-descriptions-item label="ID">{{ item.id }}</a-descriptions-item>
<!-- 原始属性 -->
<template v-if="item.data?.original?.properties">
<a-descriptions-item
v-for="(value, key) in item.data.original.properties"
:key="key"
:label="key"
>
{{ value }}
</a-descriptions-item>
</template>
<!-- 标签 -->
<a-descriptions-item label="标签" v-if="item.data?.original?.labels">
<div class="tags-container">
<a-tag v-for="tag in item.data.original.labels" :key="tag" color="blue">{{ tag }}</a-tag>
</div>
</a-descriptions-item>
</template>
<template v-else-if="type === 'edge'">
<a-descriptions-item label="类型">{{ item.data?.label }}</a-descriptions-item>
<!-- 原始属性 -->
<template v-if="item.data?.original?.properties">
<a-descriptions-item
v-for="(value, key) in filteredEdgeProperties"
:key="key"
:label="key"
>
{{ value }}
</a-descriptions-item>
</template>
</template>
</a-descriptions>
</template>
</div>
</a-card>
</div>
</transition>
</template>
<script setup>
import { computed } from 'vue';
import { CloseOutlined } from '@ant-design/icons-vue';
const props = defineProps({
visible: Boolean,
item: Object,
type: String // 'node' | 'edge'
});
defineEmits(['close']);
const title = computed(() => {
return props.type === 'node' ? '节点详情' : '关系详情';
});
//
const filteredEdgeProperties = computed(() => {
if (!props.item?.data?.original?.properties) {
return {};
}
const properties = props.item.data.original.properties;
const filtered = {};
//
const hiddenFields = ['source_id', 'target_id', '_id', 'truncate'];
Object.keys(properties).forEach(key => {
if (!hiddenFields.includes(key)) {
filtered[key] = properties[key];
}
});
return filtered;
});
</script>
<style scoped lang="less">
.detail-card {
position: fixed; // fixed
top: 80px;
right: 24px;
width: 300px;
max-height: calc(100% - 100px);
overflow-y: auto;
z-index: 1000;
pointer-events: auto; //
.info-card {
background: var(--color-bg-container);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 4px 12px var(--shadow-3);
border-radius: 4px;
border: 1px solid var(--gray-200);
:deep(.ant-card-body) {
padding: 12px;
}
:deep(.ant-descriptions-item-label),
:deep(.ant-descriptions-item-content) {
font-size: 12px;
padding: 4px 8px !important;
}
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-weight: 600;
.close-icon {
cursor: pointer;
color: var(--gray-500);
transition: color 0.2s;
&:hover {
color: var(--gray-800);
}
}
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}
/* 卡片过渡动画 */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
</style>

View File

@ -8,14 +8,49 @@
<p>只有 LightRAG 类型的知识库支持知识图谱</p>
</div>
</div>
<KnowledgeGraphViewer
v-else
:initial-database-id="databaseId"
:hide-db-selector="true"
:initial-limit="graphLimit"
:initial-depth="graphDepth"
ref="graphViewerRef"
/>
<div v-else class="graph-wrapper">
<GraphCanvas
ref="graphRef"
:graph-data="graph.graphData"
@node-click="graph.handleNodeClick"
@edge-click="graph.handleEdgeClick"
@canvas-click="graph.handleCanvasClick"
>
<template #top>
<div class="compact-actions">
<a-input-search
v-model:value="searchInput"
placeholder="搜索实体"
style="width: 200px"
@search="onSearch"
allow-clear
/>
<a-button
type="text"
:icon="h(ReloadOutlined)"
:loading="graph.fetching"
@click="loadGraph"
title="刷新"
/>
<a-button
type="text"
:icon="h(SettingOutlined)"
@click="showSettings = true"
title="设置"
/>
</div>
</template>
</GraphCanvas>
<!-- 详情浮动卡片 -->
<GraphDetailPanel
:visible="graph.showDetailDrawer"
:item="graph.selectedItem"
:type="graph.selectedItemType"
@close="graph.handleCanvasClick"
style="top: 50px; right: 10px;"
/>
</div>
</div>
<!-- 设置模态框 -->
@ -53,40 +88,19 @@
</a-form>
</div>
</a-modal>
<!-- 导出功能暂禁等待 LightRAG 库修复
<a-modal
v-model:open="showExportModal"
title="导出图谱数据"
@ok="handleExport"
ok-text="导出"
cancel-text="取消"
>
<a-form layout="vertical">
<a-form-item label="导出格式">
<a-select v-model:value="exportOptions.format">
<a-select-option value="zip">ZIP 压缩包</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="exportOptions.include_vectors" disabled>
包含向量数据 (暂不支持)
</a-checkbox>
</a-form-item>
</a-form>
</a-modal>
-->
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onUnmounted } from 'vue';
import { ref, computed, watch, nextTick, onUnmounted, reactive, h } from 'vue';
import { useDatabaseStore } from '@/stores/database';
import { ReloadOutlined } from '@ant-design/icons-vue';
import KnowledgeGraphViewer from '@/components/KnowledgeGraphViewer.vue';
import { h } from 'vue';
import { ReloadOutlined, SettingOutlined } from '@ant-design/icons-vue';
import GraphCanvas from '@/components/GraphCanvas.vue';
import GraphDetailPanel from '@/components/GraphDetailPanel.vue';
import { getKbTypeLabel } from '@/utils/kb_utils';
import { unifiedApi } from '@/apis/graph_api';
import { message } from 'ant-design-vue';
import { useGraph } from '@/composables/useGraph';
const props = defineProps({
active: {
@ -95,25 +109,19 @@ const props = defineProps({
},
});
//
const emit = defineEmits(['toggleVisible']);
const store = useDatabaseStore();
const databaseId = computed(() => store.databaseId);
const kbType = computed(() => store.database.kb_type);
const kbTypeLabel = computed(() => getKbTypeLabel(kbType.value || 'lightrag'));
const graphViewerRef = ref(null);
const graphRef = ref(null);
const showSettings = ref(false);
const graphLimit = ref(50);
const graphDepth = ref(2);
const searchInput = ref('');
const showExportModal = ref(false);
const exportOptions = ref({
format: 'zip',
include_vectors: false,
});
const graph = reactive(useGraph(graphRef));
//
const isGraphSupported = computed(() => {
@ -124,104 +132,40 @@ const isGraphSupported = computed(() => {
let pendingLoadTimer = null;
const loadGraph = async () => {
console.log('loadGraph 调用:', {
hasRef: !!graphViewerRef.value,
hasLoadFullGraph: graphViewerRef.value && typeof graphViewerRef.value.loadFullGraph === 'function'
});
if (!databaseId.value || !isGraphSupported.value) return;
//
await nextTick();
if (graphViewerRef.value && typeof graphViewerRef.value.loadFullGraph === 'function') {
console.log('调用 loadFullGraph');
graphViewerRef.value.loadFullGraph();
} else {
console.warn('无法调用 loadFullGraph:', {
hasRef: !!graphViewerRef.value,
refValue: graphViewerRef.value,
hasMethod: graphViewerRef.value && typeof graphViewerRef.value.loadFullGraph
graph.fetching = true;
try {
const res = await unifiedApi.getSubgraph({
db_id: databaseId.value,
node_label: searchInput.value || '*',
max_nodes: graphLimit.value,
max_depth: graphDepth.value
});
}
};
const clearGraph = () => {
if (graphViewerRef.value && typeof graphViewerRef.value.clearGraph === 'function') {
graphViewerRef.value.clearGraph();
if (res.success && res.data) {
graph.updateGraphData(res.data.nodes, res.data.edges);
}
} catch (e) {
console.error('Failed to load graph:', e);
message.error('加载图谱失败');
} finally {
graph.fetching = false;
}
};
const applySettings = () => {
showSettings.value = false;
// props
loadGraph();
};
// const handleExport = async () => {
// const dbId = store.databaseId;
// if (!dbId) {
// message.error('');
// return;
// }
// try {
// const response = await fetch(`/api/knowledge/databases/${dbId}/export?format=${exportOptions.value.format}`, {
// headers: {
// ...userStore.getAuthHeaders()
// }
// });
// if (!response.ok) {
// const errorData = await response.json();
// throw new Error(errorData.detail || `: ${response.statusText}`);
// }
// const blob = await response.blob();
// const url = window.URL.createObjectURL(blob);
// const a = document.createElement('a');
// a.style.display = 'none';
// a.href = url;
// const disposition = response.headers.get('content-disposition');
// let filename = `export_${dbId}.zip`;
// if (disposition) {
// const filenameMatch = disposition.match(/filename="([^"]+)"/);
// if (filenameMatch && filenameMatch[1]) {
// filename = filenameMatch[1];
// }
// }
// a.download = filename;
// document.body.appendChild(a);
// a.click();
// window.URL.revokeObjectURL(url);
// document.body.removeChild(a);
// message.success('');
// showExportModal.value = false;
// } catch (error) {
// console.error(':', error);
// message.error(error.message || '');
// }
// };
const onSearch = () => {
loadGraph();
}
const scheduleGraphLoad = (delay = 200) => {
console.log('scheduleGraphLoad 调用:', {
active: props.active,
supported: isGraphSupported.value,
databaseId: databaseId.value,
hasGraphViewer: !!graphViewerRef.value
});
//
if (!props.active) {
console.log('组件未激活,跳过图谱加载');
return;
}
if (!isGraphSupported.value) {
console.log('数据库不支持图谱功能,跳过加载');
return;
}
if (!databaseId.value) {
console.log('没有选中数据库,跳过图谱加载');
if (!props.active || !isGraphSupported.value || !databaseId.value) {
return;
}
@ -230,16 +174,8 @@ const scheduleGraphLoad = (delay = 200) => {
}
pendingLoadTimer = setTimeout(async () => {
await nextTick();
//
if (props.active && isGraphSupported.value && databaseId.value) {
console.log('执行图谱加载');
await loadGraph();
} else {
console.log('延迟检查时条件不满足:', {
active: props.active,
supported: isGraphSupported.value,
databaseId: databaseId.value
});
}
}, delay);
};
@ -256,9 +192,7 @@ watch(
);
watch(databaseId, () => {
clearGraph();
//
graph.clearGraph();
if (isGraphSupported.value) {
scheduleGraphLoad(300);
}
@ -266,7 +200,7 @@ watch(databaseId, () => {
watch(isGraphSupported, (supported) => {
if (!supported) {
clearGraph();
graph.clearGraph();
return;
}
scheduleGraphLoad(200);
@ -287,12 +221,34 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.graph-container-compact {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.graph-wrapper {
height: 100%;
width: 100%;
position: relative;
}
.compact-actions {
position: absolute;
top: 10px;
left: 10px;
display: flex;
align-items: center;
gap: 8px;
background: var(--color-trans-light);
backdrop-filter: blur(4px);
padding: 6px;
border-radius: 8px;
box-shadow: 0 0px 4px var(--shadow-3);
}
.graph-disabled {
@ -310,4 +266,4 @@ onUnmounted(() => {
margin-bottom: 8px;
}
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,120 @@
<template>
<div class="lightrag-info-panel">
<div class="info-item">
<span class="info-label">节点</span>
<span class="info-value">{{ stats?.total_nodes || 0 }}</span>
</div>
<div class="info-item">
<span class="info-label"></span>
<span class="info-value">{{ stats?.total_edges || 0 }}</span>
</div>
<div class="info-item">
<span class="info-label">知识库</span>
<span class="info-value">{{ databaseName }}</span>
</div>
<div class="actions">
<a-button type="default" size="small" @click="$emit('export-data')">
<ExportOutlined />
导出数据
</a-button>
</div>
</div>
</template>
<script setup>
import { ExportOutlined } from '@ant-design/icons-vue'
import { computed } from 'vue'
const props = defineProps({
stats: {
type: Object,
default: () => ({})
},
graphData: {
type: Object,
default: () => ({ nodes: [], edges: [] })
},
databaseName: {
type: String,
default: ''
}
})
defineEmits(['export-data'])
</script>
<style lang="less" scoped>
.lightrag-info-panel {
display: flex;
align-items: center;
gap: 24px;
padding: 8px 16px;
background: var(--gray-50);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 8px;
margin: 0;
border: 1px solid var(--gray-0);
flex-wrap: wrap;
align-self: flex-start;
@media (max-width: 768px) {
gap: 16px;
padding: 12px 16px;
}
}
.info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.info-label {
color: var(--gray-700);
font-weight: 500;
}
.info-value {
color: var(--gray-1000);
font-weight: 600;
}
.actions {
margin-left: auto;
display: flex;
gap: 8px;
@media (max-width: 768px) {
margin-left: 0;
width: 100%;
justify-content: flex-end;
}
}
:deep(.ant-btn-default) {
font-size: 12px;
height: 28px;
padding: 0 12px;
border-radius: 6px;
border-color: var(--main-300);
color: var(--main-600);
background: var(--main-20);
transition: all 0.2s ease;
&:hover {
border-color: var(--main-500);
color: var(--main-700);
background: var(--main-40);
box-shadow: 0 2px 4px rgba(1, 97, 121, 0.1);
}
&:active {
transform: translateY(1px);
}
}
</style>

View File

@ -0,0 +1,72 @@
import { ref, reactive, nextTick } from 'vue';
export function useGraph(graphRef) {
const fetching = ref(false);
const showDetailDrawer = ref(false);
const selectedItem = ref(null);
const selectedItemType = ref(null);
const graphData = reactive({
nodes: [],
edges: [],
});
const handleNodeClick = (nodeData) => {
selectedItem.value = nodeData;
selectedItemType.value = 'node';
showDetailDrawer.value = true;
console.log('Node clicked:', nodeData);
};
const handleEdgeClick = (edgeData) => {
selectedItem.value = edgeData;
selectedItemType.value = 'edge';
showDetailDrawer.value = true;
console.log('Edge clicked:', edgeData);
};
const handleCanvasClick = () => {
showDetailDrawer.value = false;
selectedItem.value = null;
selectedItemType.value = null;
// Clear focus state on the graph component if available
if (graphRef && graphRef.value && graphRef.value.clearFocus) {
graphRef.value.clearFocus();
}
};
const clearGraph = () => {
graphData.nodes = [];
graphData.edges = [];
handleCanvasClick();
};
const updateGraphData = (nodes, edges) => {
graphData.nodes = nodes || [];
graphData.edges = edges || [];
// Refresh graph visual after data update
refreshGraph();
};
const refreshGraph = () => {
nextTick(() => {
if (graphRef && graphRef.value && graphRef.value.refreshGraph) {
graphRef.value.refreshGraph();
}
});
};
return {
fetching,
graphData,
showDetailDrawer,
selectedItem,
selectedItemType,
handleNodeClick,
handleEdgeClick,
handleCanvasClick,
clearGraph,
updateGraphData,
refreshGraph,
};
}

View File

@ -13,13 +13,23 @@
title="图数据库"
>
<template #actions>
<div class="db-selector">
<div class="status-wrapper">
<div class="status-indicator" :class="graphStatusClass"></div>
<span class="status-text">{{ graphStatusText }}</span>
</div>
<a-button type="default" @click="openLink('http://localhost:7474/')" :icon="h(GlobalOutlined)">
<span class="label">知识库: </span>
<a-select
v-model:value="state.selectedDbId"
style="width: 200px"
:options="state.dbOptions"
@change="handleDbChange"
:loading="state.loadingDatabases"
/>
</div>
<!-- <a-button type="default" @click="openLink('http://localhost:7474/')" :icon="h(GlobalOutlined)">
Neo4j 浏览器
</a-button>
</a-button> -->
<a-button type="primary" @click="state.showModal = true" ><UploadOutlined/> 上传文件</a-button>
<a-button v-if="unindexedCount > 0" type="primary" @click="indexNodes" :loading="state.indexing">
<SyncOutlined v-if="!state.indexing"/> {{ unindexedCount }}个节点添加索引
@ -30,15 +40,18 @@
<div class="container-outter">
<GraphCanvas
ref="graphRef"
:graph-data="graphData"
:graph-data="graph.graphData"
:highlight-keywords="[state.searchInput]"
@node-click="graph.handleNodeClick"
@edge-click="graph.handleEdgeClick"
@canvas-click="graph.handleCanvasClick"
>
<template #top>
<div class="actions">
<div class="actions-left">
<a-input
v-model:value="state.searchInput"
placeholder="输入要查询的实体"
:placeholder="isNeo4j ? '输入要查询的实体' : '输入要查询的实体 (*为全部)'"
style="width: 300px"
@keydown.enter="onSearch"
allow-clear
@ -47,44 +60,61 @@
<component :is="state.searchLoading ? LoadingOutlined : SearchOutlined" @click="onSearch" />
</template>
</a-input>
<a-input
v-model:value="sampleNodeCount"
placeholder="查询数量"
style="width: 100px"
@keydown.enter="loadSampleNodes"
:loading="graph.fetching"
>
<template #suffix>
<component :is="graph.fetching ? LoadingOutlined : ReloadOutlined" @click="loadSampleNodes" />
</template>
</a-input>
</div>
<div class="actions-right">
<a-button type="default" @click="state.showInfoModal = true" :icon="h(InfoCircleOutlined)">
说明
</a-button>
<a-input
v-model:value="sampleNodeCount"
placeholder="查询三元组数量"
style="width: 100px"
@keydown.enter="loadSampleNodes"
:loading="state.fetching"
>
<template #suffix>
<component :is="state.fetching ? LoadingOutlined : ReloadOutlined" @click="loadSampleNodes" />
</template>
</a-input>
</div>
</div>
</template>
<template #content>
<a-empty v-show="graphData.nodes.length === 0" style="padding: 4rem 0;"/>
<a-empty v-show="graph.graphData.nodes.length === 0" style="padding: 4rem 0;"/>
</template>
<template #bottom>
<div class="footer">
<GraphInfoPanel
v-if="isNeo4j"
:graph-info="graphInfo"
:graph-data="graphData"
:graph-data="graph.graphData"
:unindexed-count="unindexedCount"
:model-matched="modelMatched"
@index-nodes="indexNodes"
@export-data="exportGraphData"
/>
<LightRAGInfoPanel
v-else
:stats="state.lightragStats"
:graph-data="graph.graphData"
:database-name="getDatabaseName()"
@export-data="exportGraphData"
/>
</div>
</template>
</GraphCanvas>
</div>
</GraphCanvas>
<!-- 详情浮动卡片 -->
<GraphDetailPanel
:visible="graph.showDetailDrawer"
:item="graph.selectedItem"
:type="graph.selectedItemType"
:nodes="graph.graphData.nodes"
@close="graph.handleCanvasClick"
style="width: 380px;"
/>
</div>
<a-modal
<a-modal
:open="state.showModal" title="上传文件"
@ok="addDocumentByFile"
@cancel="() => state.showModal = false"
@ -122,7 +152,7 @@
:footer="null"
width="600px"
>
<div class="info-content">
<div class="info-content" v-if="isNeo4j">
<p>本页面展示的是 Neo4j 图数据库中的知识图谱信息</p>
<p>具体展示内容包括</p>
<ul>
@ -132,15 +162,13 @@
<p>注意</p>
<ul>
<li>这里仅展示用户上传的实体和关系不包含知识库中自动创建的图谱</li>
<li>查询逻辑基于 <code>graphbase.py</code> 中的 <code>get_sample_nodes</code> 方法实现</li>
<li>查询逻辑基于 <code>graphbase.py</code> 中的 <code>get_sample_nodes</code> 方法实现</li>
</ul>
<pre><code>MATCH (n:Entity)-[r]-&gt;(m:Entity)
RETURN
{id: elementId(n), name: n.name} AS h,
{type: r.type, source_id: elementId(n), target_id: elementId(m)} AS r,
{id: elementId(m), name: m.name} AS t
LIMIT $num</code></pre>
<p>如需查看完整的 Neo4j 数据库内容请使用 "Neo4j 浏览器" 按钮访问原生界面</p>
</div>
<div class="info-content" v-else>
<p>本页面展示的是 LightRAG 知识库生成的图谱信息</p>
<p>数据来源于选定的知识库实例</p>
<p>支持通过实体名称进行模糊搜索输入 "*" 可查看采样全图</p>
</div>
</a-modal>
</div>
@ -152,10 +180,14 @@ import { message } from 'ant-design-vue';
import { useConfigStore } from '@/stores/config';
import { UploadOutlined, SyncOutlined, GlobalOutlined, InfoCircleOutlined, SearchOutlined, ReloadOutlined, LoadingOutlined, HighlightOutlined } from '@ant-design/icons-vue';
import HeaderComponent from '@/components/HeaderComponent.vue';
import { neo4jApi } from '@/apis/graph_api';
import { neo4jApi, unifiedApi } from '@/apis/graph_api';
import { useUserStore } from '@/stores/user';
import GraphCanvas from '@/components/GraphCanvas.vue';
import GraphInfoPanel from '@/components/GraphInfoPanel.vue';
import LightRAGInfoPanel from '@/components/LightRAGInfoPanel.vue';
import GraphDetailPanel from '@/components/GraphDetailPanel.vue';
import UploadModal from '@/components/FileUploadModal.vue';
import { useGraph } from '@/composables/useGraph';
const configStore = useConfigStore();
const cur_embed_model = computed(() => configStore.config?.embed_model);
@ -165,14 +197,12 @@ const graphRef = ref(null)
const graphInfo = ref(null)
const fileList = ref([]);
const sampleNodeCount = ref(100);
const graphData = reactive({
nodes: [],
edges: [],
});
const graph = reactive(useGraph(graphRef));
const state = reactive({
fetching: false,
loadingGraphInfo: false,
loadingDatabases: false,
searchInput: '',
searchLoading: false,
showModal: false,
@ -180,13 +210,66 @@ const state = reactive({
processing: false,
indexing: false,
showPage: true,
selectedDbId: 'neo4j',
dbOptions: [],
lightragStats: null,
})
const isNeo4j = computed(() => state.selectedDbId === 'neo4j');
//
const unindexedCount = computed(() => {
return graphInfo.value?.unindexed_node_count || 0;
});
const loadDatabases = async () => {
state.loadingDatabases = true;
try {
const res = await unifiedApi.getGraphs();
if (res.success && res.data) {
state.dbOptions = res.data.map(db => ({
label: `${db.name} (${db.type})`,
value: db.id,
type: db.type
}));
// If no selection or invalid selection, select first
if (!state.selectedDbId || !state.dbOptions.find(o => o.value === state.selectedDbId)) {
if (state.dbOptions.length > 0) {
state.selectedDbId = state.dbOptions[0].value;
}
}
}
} catch (error) {
console.error('Failed to load databases:', error);
} finally {
state.loadingDatabases = false;
}
};
const handleDbChange = () => {
// Clear current data
graph.clearGraph();
state.searchInput = '';
state.lightragStats = null;
if (isNeo4j.value) {
loadGraphInfo();
} else {
// Also load stats for LightRAG
loadLightRAGStats();
}
loadSampleNodes();
};
const loadLightRAGStats = () => {
unifiedApi.getStats(state.selectedDbId).then(res => {
if(res.success) {
state.lightragStats = res.data;
}
}).catch(e => console.error(e));
};
const loadGraphInfo = () => {
state.loadingGraphInfo = true
neo4jApi.getInfo()
@ -222,20 +305,24 @@ const addDocumentByFile = () => {
};
const loadSampleNodes = () => {
state.fetching = true
neo4jApi.getSampleNodes('neo4j', sampleNodeCount.value)
graph.fetching = true
unifiedApi.getSubgraph({
db_id: state.selectedDbId,
node_label: '*',
max_nodes: sampleNodeCount.value
})
.then((data) => {
graphData.nodes = data.result.nodes
graphData.edges = data.result.edges
console.log(graphData)
//
setTimeout(() => graphRef.value?.refreshGraph?.(), 500)
// Normalize data structure if needed
const result = data.data;
graph.updateGraphData(result.nodes, result.edges);
console.log(graph.graphData)
})
.catch((error) => {
console.error(error)
message.error(error.message || '加载节点失败');
})
.finally(() => state.fetching = false)
.finally(() => graph.fetching = false)
}
const onSearch = () => {
@ -244,29 +331,33 @@ const onSearch = () => {
return
}
if (graphInfo?.value?.embed_model_name !== cur_embed_model.value) {
if (isNeo4j.value && graphInfo?.value?.embed_model_name !== cur_embed_model.value) {
//
}
if (!state.searchInput) {
if (!state.searchInput && isNeo4j.value) {
message.error('请输入要查询的实体')
return
}
state.searchLoading = true
neo4jApi.queryNode(state.searchInput)
unifiedApi.getSubgraph({
db_id: state.selectedDbId,
node_label: state.searchInput || '*',
max_nodes: sampleNodeCount.value
})
.then((data) => {
if (!data.result || !data.result.nodes || !data.result.edges) {
const result = data.data;
if (!result || !result.nodes || !result.edges) {
throw new Error('返回数据格式不正确');
}
graphData.nodes = data.result.nodes
graphData.edges = data.result.edges
if (graphData.nodes.length === 0) {
graph.updateGraphData(result.nodes, result.edges);
if (graph.graphData.nodes.length === 0) {
message.info('未找到相关实体')
}
console.log(data)
console.log(graphData)
graphRef.value?.refreshGraph?.()
console.log(graph.graphData)
})
.catch((error) => {
console.error('查询错误:', error);
@ -275,8 +366,9 @@ const onSearch = () => {
.finally(() => state.searchLoading = false)
};
onMounted(() => {
loadGraphInfo();
onMounted(async () => {
await loadDatabases();
loadGraphInfo(); // Load default (Neo4j) info
loadSampleNodes();
});
@ -332,9 +424,10 @@ const indexNodes = () => {
const exportGraphData = () => {
const dataStr = JSON.stringify({
nodes: graphData.nodes,
edges: graphData.edges,
graphInfo: graphInfo.value,
nodes: graph.graphData.nodes,
edges: graph.graphData.edges,
graphInfo: isNeo4j.value ? graphInfo.value : state.lightragStats,
source: state.selectedDbId,
exportTime: new Date().toISOString()
}, null, 2);
@ -342,7 +435,7 @@ const exportGraphData = () => {
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `graph-data-${new Date().toISOString().slice(0, 10)}.json`;
link.download = `graph-data-${state.selectedDbId}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@ -360,6 +453,11 @@ const openLink = (url) => {
window.open(url, '_blank')
}
const getDatabaseName = () => {
const selectedDb = state.dbOptions.find(db => db.value === state.selectedDbId);
return selectedDb ? selectedDb.label : state.selectedDbId;
};
</script>
<style lang="less" scoped>
@ -374,6 +472,18 @@ const openLink = (url) => {
}
}
.db-selector {
display: flex;
align-items: center;
margin-right: 20px;
.label {
margin-right: 8px;
font-weight: 500;
color: var(--color-text-secondary);
}
}
.status-wrapper {
display: flex;
align-items: center;
@ -469,4 +579,4 @@ const openLink = (url) => {
box-shadow: none;
}
}
</style>
</style>