feat: 实现统一图谱适配器及前端组件重构
重构图谱系统架构,引入适配器模式支持多种图数据库类型。主要变更包括: 1. 新增 GraphAdapter 基类及 LightRAG/Upload 适配器实现 2. 重构前端组件结构,新增 GraphDetailPanel 等可复用组件 3. 实现 useGraph 组合式函数统一管理图谱状态 4. 优化 Neo4j 节点/边数据标准化处理 5. 新增统一图谱 API 接口及对应测试用例 6. 改进前端交互体验,增加节点详情展示等功能 前端组件库重构为模块化结构,提升代码复用性。适配器模式使系统可灵活支持不同图数据源,为后续扩展奠定基础。 Next:上传图谱文件的时候,支持属性解析
This commit is contained in:
parent
225ccce69c
commit
3d63418adf
@ -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 {
|
||||
|
||||
@ -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 中的工具列表
|
||||
|
||||
6
src/knowledge/adapters/__init__.py
Normal file
6
src/knowledge/adapters/__init__.py
Normal 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"]
|
||||
87
src/knowledge/adapters/base.py
Normal file
87
src/knowledge/adapters/base.py
Normal 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,
|
||||
},
|
||||
}
|
||||
23
src/knowledge/adapters/factory.py
Normal file
23
src/knowledge/adapters/factory.py
Normal 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)
|
||||
154
src/knowledge/adapters/lightrag.py
Normal file
154
src/knowledge/adapters/lightrag.py
Normal 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
|
||||
112
src/knowledge/adapters/upload.py
Normal file
112
src/knowledge/adapters/upload.py
Normal 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}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
138
test/api/test_unified_graph_router.py
Normal file
138
test/api/test_unified_graph_router.py
Normal 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"
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
167
web/src/components/GraphDetailPanel.vue
Normal file
167
web/src/components/GraphDetailPanel.vue
Normal 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>
|
||||
@ -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
120
web/src/components/LightRAGInfoPanel.vue
Normal file
120
web/src/components/LightRAGInfoPanel.vue
Normal 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>
|
||||
72
web/src/composables/useGraph.js
Normal file
72
web/src/composables/useGraph.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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]->(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>
|
||||
Loading…
Reference in New Issue
Block a user