Merge branch 'feat/graph'
This commit is contained in:
commit
310fda71bc
@ -97,16 +97,32 @@ LIMIT $num
|
||||
|
||||
### 1. 以三元组形式导入
|
||||
|
||||
系统支持通过网页导入 `jsonl` 格式的知识图谱数据,支持**简单三元组**和**带属性三元组**两种格式。
|
||||
|
||||
系统支持通过网页导入 `jsonl` 格式的知识图谱数据:
|
||||
**简单格式(兼容旧版)**:
|
||||
|
||||
```jsonl
|
||||
{"h": "北京", "t": "中国", "r": "首都"}
|
||||
{"h": "上海", "t": "中国", "r": "直辖市"}
|
||||
{"h": "深圳", "t": "广东", "r": "省会"}
|
||||
```
|
||||
|
||||
**格式说明**,每行一个三元组,系统自动验证数据格式,并自动导入到 Neo4j 数据库,添加 `Upload`、`Entity`、`Relation` 标签,会自动处理重复的三元组。
|
||||
**扩展格式(支持属性)**:
|
||||
|
||||
支持 `h`(头节点)、`t`(尾节点)和 `r`(关系)为对象结构,其中:
|
||||
- 节点对象必须包含 `name` 字段。
|
||||
- 关系对象必须包含 `type` 字段。
|
||||
- 其他字段将作为**属性**存储在 Neo4j 中。
|
||||
|
||||
```jsonl
|
||||
{"h": {"name": "孙悟空", "title": "齐天大圣", "weapon": "如意金箍棒"}, "t": {"name": "唐僧", "species": "人"}, "r": {"type": "徒弟", "order": 1}}
|
||||
{"h": "猪八戒", "t": {"name": "唐僧"}, "r": {"type": "徒弟", "order": 2}}
|
||||
```
|
||||
|
||||
**格式说明**:
|
||||
- 每行一个数据项。
|
||||
- 系统自动验证数据格式,并自动导入到 Neo4j 数据库。
|
||||
- 自动添加 `Upload`、`Entity` 标签(节点)和 `RELATION` 类型(关系)。
|
||||
- 自动处理重复实体和关系,并合并属性。
|
||||
|
||||
Neo4j 访问信息可以参考 `docker-compose.yml` 中配置对应的环境变量来覆盖。
|
||||
|
||||
@ -116,7 +132,9 @@ Neo4j 访问信息可以参考 `docker-compose.yml` 中配置对应的环境变
|
||||
- **连接地址**: bolt://localhost:7687
|
||||
|
||||
::: tip 测试数据
|
||||
可以使用 `test/data/A_Dream_of_Red_Mansions_tiny.jsonl` 文件进行测试导入。
|
||||
可以使用以下文件进行测试导入:
|
||||
- 简单格式:`test/data/A_Dream_of_Red_Mansions_tiny.jsonl`
|
||||
- 扩展属性格式:`test/data/complex_graph_test.jsonl`
|
||||
:::
|
||||
|
||||
### 2. 接入已有 Neo4j 实例
|
||||
|
||||
@ -5,13 +5,213 @@ 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 +223,36 @@ 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 +261,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 +296,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 中的工具列表
|
||||
|
||||
@ -38,7 +38,9 @@ def get_connection_manager() -> MySQLConnectionManager:
|
||||
required_keys = ["host", "user", "password", "database"]
|
||||
for key in required_keys:
|
||||
if not mysql_config[key]:
|
||||
raise MySQLConnectionError(f"MySQL configuration missing required key: {key}")
|
||||
raise MySQLConnectionError(
|
||||
f"MySQL configuration missing required key: {key}, please check your environment variables."
|
||||
)
|
||||
|
||||
_connection_manager = MySQLConnectionManager(mysql_config)
|
||||
return _connection_manager
|
||||
|
||||
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)
|
||||
145
src/knowledge/adapters/lightrag.py
Normal file
145
src/knowledge/adapters/lightrag.py
Normal file
@ -0,0 +1,145 @@
|
||||
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
|
||||
109
src/knowledge/adapters/upload.py
Normal file
109
src/knowledge/adapters/upload.py
Normal file
@ -0,0 +1,109 @@
|
||||
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}
|
||||
@ -64,6 +64,22 @@ class GraphDatabase:
|
||||
assert self.driver is not None, "Database is not connected"
|
||||
self.use_database(kgdb_name)
|
||||
|
||||
def _process_record_props(record):
|
||||
"""处理记录中的属性:扁平化 properties 并移除 embedding"""
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
# 复制一份以避免修改原字典
|
||||
data = dict(record)
|
||||
props = data.pop("properties", {}) or {}
|
||||
|
||||
# 移除 embedding
|
||||
if "embedding" in props:
|
||||
del props["embedding"]
|
||||
|
||||
# 合并属性(优先保留原字典中的 id, name, type 等核心字段)
|
||||
return {**props, **data}
|
||||
|
||||
def query(tx, num):
|
||||
"""Note: 使用连通性查询获取集中的节点子图"""
|
||||
# 首先尝试获取一个连通的子图
|
||||
@ -104,12 +120,18 @@ class GraphDatabase:
|
||||
OPTIONAL MATCH (n)-[rel]-(m)
|
||||
WHERE m IN final_nodes AND elementId(n) < elementId(m)
|
||||
RETURN
|
||||
{id: elementId(n), name: n.name} AS h,
|
||||
{id: elementId(n), name: n.name, properties: properties(n)} 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)),
|
||||
properties: properties(rel)
|
||||
}
|
||||
ELSE null END AS r,
|
||||
CASE WHEN m IS NOT NULL THEN
|
||||
{id: elementId(m), name: m.name}
|
||||
{id: elementId(m), name: m.name, properties: properties(m)}
|
||||
ELSE null END AS t
|
||||
"""
|
||||
|
||||
@ -119,7 +141,7 @@ class GraphDatabase:
|
||||
node_ids = set()
|
||||
|
||||
for item in results:
|
||||
h_node = item["h"]
|
||||
h_node = _process_record_props(item["h"])
|
||||
|
||||
# 始终添加头节点
|
||||
if h_node["id"] not in node_ids:
|
||||
@ -128,14 +150,15 @@ class GraphDatabase:
|
||||
|
||||
# 只有当边和尾节点都存在时才处理
|
||||
if item["r"] is not None and item["t"] is not None:
|
||||
t_node = item["t"]
|
||||
t_node = _process_record_props(item["t"])
|
||||
r_edge = _process_record_props(item["r"])
|
||||
|
||||
# 避免重复添加尾节点
|
||||
if t_node["id"] not in node_ids:
|
||||
formatted_results["nodes"].append(t_node)
|
||||
node_ids.add(t_node["id"])
|
||||
|
||||
formatted_results["edges"].append(item["r"])
|
||||
formatted_results["edges"].append(r_edge)
|
||||
|
||||
# 如果连通查询返回的节点数不足,补充更多节点
|
||||
if len(formatted_results["nodes"]) < num:
|
||||
@ -145,14 +168,14 @@ class GraphDatabase:
|
||||
supplement_query = """
|
||||
MATCH (n:Entity)
|
||||
WHERE NOT elementId(n) IN $existing_ids
|
||||
RETURN {id: elementId(n), name: n.name} AS node
|
||||
RETURN {id: elementId(n), name: n.name, properties: properties(n)} AS node
|
||||
LIMIT $count
|
||||
"""
|
||||
|
||||
supplement_results = tx.run(supplement_query, existing_ids=list(node_ids), count=remaining_count)
|
||||
|
||||
for item in supplement_results:
|
||||
node = item["node"]
|
||||
node = _process_record_props(item["node"])
|
||||
formatted_results["nodes"].append(node)
|
||||
node_ids.add(node["id"])
|
||||
|
||||
@ -165,9 +188,15 @@ class GraphDatabase:
|
||||
MATCH (n:Entity)-[r]-(m:Entity)
|
||||
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(m), name: m.name} AS t
|
||||
{id: elementId(n), name: n.name, properties: properties(n)} AS h,
|
||||
{
|
||||
id: elementId(r),
|
||||
type: r.type,
|
||||
source_id: elementId(startNode(r)),
|
||||
target_id: elementId(endNode(r)),
|
||||
properties: properties(r)
|
||||
} AS r,
|
||||
{id: elementId(m), name: m.name, properties: properties(m)} AS t
|
||||
LIMIT $num
|
||||
"""
|
||||
results = tx.run(fallback_query, num=int(num))
|
||||
@ -175,8 +204,9 @@ class GraphDatabase:
|
||||
node_ids = set()
|
||||
|
||||
for item in results:
|
||||
h_node = item["h"]
|
||||
t_node = item["t"]
|
||||
h_node = _process_record_props(item["h"])
|
||||
t_node = _process_record_props(item["t"])
|
||||
r_edge = _process_record_props(item["r"])
|
||||
|
||||
# 避免重复添加节点
|
||||
if h_node["id"] not in node_ids:
|
||||
@ -186,7 +216,7 @@ class GraphDatabase:
|
||||
formatted_results["nodes"].append(t_node)
|
||||
node_ids.add(t_node["id"])
|
||||
|
||||
formatted_results["edges"].append(item["r"])
|
||||
formatted_results["edges"].append(r_edge)
|
||||
|
||||
return formatted_results
|
||||
|
||||
@ -230,18 +260,47 @@ class GraphDatabase:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _parse_node(node_data):
|
||||
"""解析节点数据,返回 (name, props)"""
|
||||
if isinstance(node_data, dict):
|
||||
props = node_data.copy()
|
||||
name = props.pop("name", "")
|
||||
return name, props
|
||||
return str(node_data), {}
|
||||
|
||||
def _parse_relation(rel_data):
|
||||
"""解析关系数据,返回 (type, props)"""
|
||||
if isinstance(rel_data, dict):
|
||||
props = rel_data.copy()
|
||||
rel_type = props.pop("type", "")
|
||||
return rel_type, props
|
||||
return str(rel_data), {}
|
||||
|
||||
def _create_graph(tx, data):
|
||||
"""添加一个三元组"""
|
||||
for entry in data:
|
||||
h_name, h_props = _parse_node(entry.get("h"))
|
||||
t_name, t_props = _parse_node(entry.get("t"))
|
||||
r_type, r_props = _parse_relation(entry.get("r"))
|
||||
|
||||
if not h_name or not t_name or not r_type:
|
||||
continue
|
||||
|
||||
tx.run(
|
||||
"""
|
||||
MERGE (h:Entity:Upload {name: $h})
|
||||
MERGE (t:Entity:Upload {name: $t})
|
||||
MERGE (h)-[r:RELATION {type: $r}]->(t)
|
||||
MERGE (h:Entity:Upload {name: $h_name})
|
||||
SET h += $h_props
|
||||
MERGE (t:Entity:Upload {name: $t_name})
|
||||
SET t += $t_props
|
||||
MERGE (h)-[r:RELATION {type: $r_type}]->(t)
|
||||
SET r += $r_props
|
||||
""",
|
||||
h=entry["h"],
|
||||
t=entry["t"],
|
||||
r=entry["r"],
|
||||
h_name=h_name,
|
||||
h_props=h_props,
|
||||
t_name=t_name,
|
||||
t_props=t_props,
|
||||
r_type=r_type,
|
||||
r_props=r_props,
|
||||
)
|
||||
|
||||
def _create_vector_index(tx, dim):
|
||||
@ -263,6 +322,9 @@ class GraphDatabase:
|
||||
# 构建参数字典,将列表转换为"param0"、"param1"等键值对形式
|
||||
params = {f"param{i}": name for i, name in enumerate(entity_names)}
|
||||
|
||||
if not params:
|
||||
return []
|
||||
|
||||
# 构建查询参数列表
|
||||
param_placeholders = ", ".join([f"${key}" for key in params.keys()])
|
||||
|
||||
@ -305,20 +367,24 @@ class GraphDatabase:
|
||||
session.execute_write(_create_vector_index, getattr(cur_embed_info, "dimension", 1024))
|
||||
|
||||
# 收集所有需要处理的实体名称,去重
|
||||
all_entities = []
|
||||
all_entities = set()
|
||||
for entry in triples:
|
||||
if entry["h"] not in all_entities:
|
||||
all_entities.append(entry["h"])
|
||||
if entry["t"] not in all_entities:
|
||||
all_entities.append(entry["t"])
|
||||
h_name, _ = _parse_node(entry.get("h"))
|
||||
t_name, _ = _parse_node(entry.get("t"))
|
||||
if h_name:
|
||||
all_entities.add(h_name)
|
||||
if t_name:
|
||||
all_entities.add(t_name)
|
||||
|
||||
all_entities_list = list(all_entities)
|
||||
|
||||
# 筛选出没有embedding的节点
|
||||
nodes_without_embedding = session.execute_read(_get_nodes_without_embedding, all_entities)
|
||||
nodes_without_embedding = session.execute_read(_get_nodes_without_embedding, all_entities_list)
|
||||
if not nodes_without_embedding:
|
||||
logger.info("所有实体已有embedding,无需重新计算")
|
||||
return
|
||||
|
||||
logger.info(f"需要为{len(nodes_without_embedding)}/{len(all_entities)}个实体计算embedding")
|
||||
logger.info(f"需要为{len(nodes_without_embedding)}/{len(all_entities_list)}个实体计算embedding")
|
||||
|
||||
# 批量处理实体
|
||||
max_batch_size = 1024 # 限制此部分的主要是内存大小 1024 * 1024 * 4 / 1024 / 1024 = 4GB
|
||||
@ -587,30 +653,70 @@ class GraphDatabase:
|
||||
|
||||
self.use_database(kgdb_name)
|
||||
|
||||
def _process_record_props(record):
|
||||
"""处理记录中的属性:扁平化 properties 并移除 embedding"""
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
# 复制一份以避免修改原字典
|
||||
data = dict(record)
|
||||
props = data.pop("properties", {}) or {}
|
||||
|
||||
# 移除 embedding
|
||||
if "embedding" in props:
|
||||
del props["embedding"]
|
||||
|
||||
# 合并属性(优先保留原字典中的 id, name, type 等核心字段)
|
||||
return {**props, **data}
|
||||
|
||||
def query(tx, entity_name, hops, limit):
|
||||
try:
|
||||
query_str = """
|
||||
WITH [
|
||||
// 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)},
|
||||
t: {id: elementId(m1), name: m1.name}}],
|
||||
{h: {id: elementId(n), name: n.name, properties: properties(n)},
|
||||
r: {
|
||||
id: elementId(r1),
|
||||
type: r1.type,
|
||||
source_id: elementId(n),
|
||||
target_id: elementId(m1),
|
||||
properties: properties(r1)
|
||||
},
|
||||
t: {id: elementId(m1), name: m1.name, properties: properties(m1)}}],
|
||||
// 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)},
|
||||
t: {id: elementId(m2), name: m2.name}}],
|
||||
{h: {id: elementId(m1), name: m1.name, properties: properties(m1)},
|
||||
r: {
|
||||
id: elementId(r2),
|
||||
type: r2.type,
|
||||
source_id: elementId(m1),
|
||||
target_id: elementId(m2),
|
||||
properties: properties(r2)
|
||||
},
|
||||
t: {id: elementId(m2), name: m2.name, properties: properties(m2)}}],
|
||||
// 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)},
|
||||
t: {id: elementId(n), name: n.name}}],
|
||||
{h: {id: elementId(m1), name: m1.name, properties: properties(m1)},
|
||||
r: {
|
||||
id: elementId(r1),
|
||||
type: r1.type,
|
||||
source_id: elementId(m1),
|
||||
target_id: elementId(n),
|
||||
properties: properties(r1)
|
||||
},
|
||||
t: {id: elementId(n), name: n.name, properties: properties(n)}}],
|
||||
// 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)},
|
||||
t: {id: elementId(m1), name: m1.name}}]
|
||||
{h: {id: elementId(m2), name: m2.name, properties: properties(m2)},
|
||||
r: {
|
||||
id: elementId(r2),
|
||||
type: r2.type,
|
||||
source_id: elementId(m2),
|
||||
target_id: elementId(m1),
|
||||
properties: properties(r2)
|
||||
},
|
||||
t: {id: elementId(m1), name: m1.name, properties: properties(m1)}}]
|
||||
] AS all_results
|
||||
UNWIND all_results AS result_list
|
||||
UNWIND result_list AS item
|
||||
@ -626,9 +732,13 @@ class GraphDatabase:
|
||||
formatted_results = {"nodes": [], "edges": [], "triples": []}
|
||||
|
||||
for item in results:
|
||||
formatted_results["nodes"].extend([item["h"], item["t"]])
|
||||
formatted_results["edges"].append(item["r"])
|
||||
formatted_results["triples"].append((item["h"]["name"], item["r"]["type"], item["t"]["name"]))
|
||||
h = _process_record_props(item["h"])
|
||||
r = _process_record_props(item["r"])
|
||||
t = _process_record_props(item["t"])
|
||||
|
||||
formatted_results["nodes"].extend([h, t])
|
||||
formatted_results["edges"].append(r)
|
||||
formatted_results["triples"].append((h["name"], r["type"], t["name"]))
|
||||
|
||||
logger.debug(f"Query Results: {results}")
|
||||
return formatted_results
|
||||
|
||||
@ -200,6 +200,7 @@ class LightRagKB(KnowledgeBase):
|
||||
def _get_embedding_func(self, embed_info: dict):
|
||||
"""获取 embedding 函数"""
|
||||
config_dict = get_embedding_config(embed_info)
|
||||
logger.debug(f"Embedding config dict: {config_dict}")
|
||||
|
||||
if config_dict.get("model_id") and config_dict["model_id"].startswith("ollama"):
|
||||
from lightrag.llm.ollama import ollama_embed
|
||||
@ -219,12 +220,19 @@ class LightRagKB(KnowledgeBase):
|
||||
),
|
||||
)
|
||||
|
||||
# 尝试获取模型名称,支持多种键名以保持兼容性
|
||||
if "name" in config_dict and config_dict["name"]:
|
||||
model_name = config_dict["name"]
|
||||
elif "model" in config_dict and config_dict["model"]:
|
||||
model_name = config_dict["model"]
|
||||
else:
|
||||
raise ValueError(f"Neither 'name' nor 'model' found in config_dict or both are empty: {config_dict}")
|
||||
return EmbeddingFunc(
|
||||
embedding_dim=config_dict["dimension"],
|
||||
max_token_size=8192,
|
||||
func=lambda texts: openai_embed(
|
||||
texts=texts,
|
||||
model=config_dict["model"],
|
||||
model=model_name,
|
||||
api_key=config_dict["api_key"],
|
||||
base_url=config_dict["base_url"].replace("/embeddings", ""),
|
||||
),
|
||||
|
||||
@ -289,30 +289,52 @@ def get_embedding_config(embed_info: dict) -> dict:
|
||||
Returns:
|
||||
dict: 标准化的嵌入配置
|
||||
"""
|
||||
config_dict = {}
|
||||
|
||||
try:
|
||||
if embed_info:
|
||||
# 优先检查是否有 model_id 字段
|
||||
if "model_id" in embed_info:
|
||||
return config.embed_model_names[embed_info["model_id"]].model_dump()
|
||||
elif hasattr(embed_info, "name") and isinstance(embed_info, EmbedModelInfo):
|
||||
return embed_info.model_dump()
|
||||
else:
|
||||
# 字典形式(保持向后兼容)
|
||||
config_dict["model"] = embed_info["name"]
|
||||
config_dict["api_key"] = os.getenv(embed_info["api_key"]) or embed_info["api_key"]
|
||||
config_dict["base_url"] = embed_info["base_url"]
|
||||
config_dict["dimension"] = embed_info.get("dimension", 1024)
|
||||
else:
|
||||
return config.embed_model_names[config.embed_model].model_dump()
|
||||
# 检查 embed_info 是否有效
|
||||
if not embed_info or ("model" not in embed_info and "name" not in embed_info):
|
||||
logger.error(f"Invalid embed_info: {embed_info}, using default embedding model config")
|
||||
raise ValueError("Invalid embed_info: must be a non-empty dictionary")
|
||||
|
||||
# 优先检查是否有 model_id 字段
|
||||
if "model_id" in embed_info and embed_info["model_id"]:
|
||||
logger.warning(f"Using model_id: {embed_info['model_id']}")
|
||||
config_dict = config.embed_model_names[embed_info["model_id"]].model_dump()
|
||||
config_dict["api_key"] = os.getenv(config_dict["api_key"]) or config_dict["api_key"]
|
||||
return config_dict
|
||||
|
||||
# 检查是否是 EmbedModelInfo 对象(在某些情况下可能直接传入对象)
|
||||
if hasattr(embed_info, "name") and isinstance(embed_info, EmbedModelInfo):
|
||||
logger.debug(f"Using EmbedModelInfo object: {embed_info.name}")
|
||||
config_dict = embed_info.model_dump()
|
||||
config_dict["api_key"] = os.getenv(config_dict["api_key"]) or config_dict["api_key"]
|
||||
return config_dict
|
||||
|
||||
# 字典形式(保持向后兼容)
|
||||
# 检查必需字段是否存在
|
||||
if not embed_info.get("name") or not embed_info.get("base_url"):
|
||||
logger.warning(f"embed_info missing required 'name' or 'base_url' field: {embed_info}, using default")
|
||||
raise ValueError("embed_info missing required 'name' or 'base_url' field")
|
||||
|
||||
config_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),
|
||||
}
|
||||
logger.debug(f"Embedding config from dict: {config_dict}")
|
||||
return config_dict
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_embedding_config: {e}, {embed_info}")
|
||||
raise ValueError(f"Error in get_embedding_config: {e}")
|
||||
|
||||
logger.debug(f"Embedding config: {config_dict}")
|
||||
return config_dict
|
||||
logger.error(f"Error in get_embedding_config: {e}, embed_info={embed_info}")
|
||||
# 返回默认配置作为fallback
|
||||
logger.warning("Falling back to default embedding model config")
|
||||
try:
|
||||
config_dict = config.embed_model_names[config.embed_model].model_dump()
|
||||
config_dict["api_key"] = os.getenv(config_dict["api_key"]) or config_dict["api_key"]
|
||||
return config_dict
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"Failed to get default embedding config: {fallback_error}")
|
||||
raise ValueError(f"Failed to get embedding config and fallback failed: {e}")
|
||||
|
||||
|
||||
def is_minio_url(file_path: str) -> bool:
|
||||
|
||||
118
test/api/test_unified_graph_router.py
Normal file
118
test/api/test_unified_graph_router.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""
|
||||
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"
|
||||
6
test/data/complex_graph_test.jsonl
Normal file
6
test/data/complex_graph_test.jsonl
Normal file
@ -0,0 +1,6 @@
|
||||
{"h": {"name": "孙悟空", "title": "齐天大圣", "weapon": "如意金箍棒", "species": "猴"}, "t": {"name": "唐僧", "title": "旃檀功德佛", "species": "人"}, "r": {"type": "徒弟", "order": 1}}
|
||||
{"h": {"name": "猪八戒", "title": "天蓬元帅", "weapon": "九齿钉耙", "species": "猪"}, "t": {"name": "唐僧"}, "r": {"type": "徒弟", "order": 2}}
|
||||
{"h": {"name": "沙悟净", "title": "卷帘大将", "weapon": "降妖宝杖"}, "t": {"name": "唐僧"}, "r": {"type": "徒弟", "order": 3}}
|
||||
{"h": {"name": "白龙马", "origin": "西海龙宫"}, "t": {"name": "唐僧"}, "r": {"type": "坐骑", "original_form": "龙"}}
|
||||
{"h": "孙悟空", "t": {"name": "猪八戒"}, "r": {"type": "师兄弟", "relationship": "conflict/cooperate"}}
|
||||
{"h": {"name": "如来佛祖", "location": "西天雷音寺"}, "t": {"name": "孙悟空"}, "r": {"type": "压制", "tool": "五指山", "duration": "500年"}}
|
||||
96
test/test_graph_unit.py
Normal file
96
test/test_graph_unit.py
Normal file
@ -0,0 +1,96 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from src.knowledge.graph import GraphDatabase
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_txt_add_vector_entity_parsing():
|
||||
# Mock driver and session
|
||||
mock_driver = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_driver.session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Setup mock transaction
|
||||
mock_tx = MagicMock()
|
||||
|
||||
def side_effect_execute_write(func, *args, **kwargs):
|
||||
return func(mock_tx, *args, **kwargs)
|
||||
|
||||
# Mock execute_read to return empty list (no missing embeddings for this test)
|
||||
# The code calls _get_nodes_without_embedding which returns [record['name']]
|
||||
# If we return [], it means all nodes have embeddings or none found.
|
||||
# Actually, the code checks:
|
||||
# nodes_without_embedding = session.execute_read(_get_nodes_without_embedding, all_entities)
|
||||
# Let's mock it to return empty list so we skip embedding generation loop which simplifies test
|
||||
mock_session.execute_read.return_value = []
|
||||
|
||||
mock_session.execute_write.side_effect = side_effect_execute_write
|
||||
|
||||
# Mock embedding model
|
||||
with patch("src.knowledge.graph.select_embedding_model") as mock_select_model:
|
||||
mock_embed_model = MagicMock()
|
||||
mock_select_model.return_value = mock_embed_model
|
||||
|
||||
# Instantiate GraphDatabase with mocked driver
|
||||
# We also need to patch GD.driver in the init
|
||||
with patch("src.knowledge.graph.GD.driver", return_value=mock_driver):
|
||||
gd = GraphDatabase()
|
||||
# Manually set driver and status just in case init didn't work as expected due to other mocks
|
||||
gd.driver = mock_driver
|
||||
gd.status = "open"
|
||||
gd.embed_model_name = "test_model" # avoid config check issues if possible
|
||||
|
||||
# Mock config to match
|
||||
with patch("src.knowledge.graph.config.embed_model", "test_model"):
|
||||
with patch("src.knowledge.graph.config.embed_model_names", {"test_model": MagicMock(dimension=1024)}):
|
||||
# Test data: Mixed format
|
||||
triples = [
|
||||
# Legacy format
|
||||
{"h": "A", "r": "KNOWS", "t": "B"},
|
||||
# Extended format
|
||||
{
|
||||
"h": {"name": "C", "age": 30},
|
||||
"r": {"type": "LIKES", "weight": 0.8},
|
||||
"t": {"name": "D", "role": "User"},
|
||||
},
|
||||
]
|
||||
|
||||
# Run the method
|
||||
await gd.txt_add_vector_entity(triples)
|
||||
|
||||
# Verify calls to mock_tx.run
|
||||
merge_calls = []
|
||||
for call in mock_tx.run.call_args_list:
|
||||
args, kwargs = call
|
||||
query = args[0] if args else kwargs.get("query", "")
|
||||
if "MERGE (h:Entity:Upload" in query:
|
||||
# The args are passed as kwargs to run: h_name=..., etc.
|
||||
merge_calls.append(kwargs)
|
||||
|
||||
assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}"
|
||||
|
||||
# Call 1 (Legacy)
|
||||
call1 = merge_calls[0]
|
||||
assert call1["h_name"] == "A"
|
||||
assert call1["h_props"] == {}
|
||||
assert call1["t_name"] == "B"
|
||||
assert call1["t_props"] == {}
|
||||
assert call1["r_type"] == "KNOWS"
|
||||
assert call1["r_props"] == {}
|
||||
|
||||
# Call 2 (Extended)
|
||||
call2 = merge_calls[1]
|
||||
assert call2["h_name"] == "C"
|
||||
assert call2["h_props"] == {"age": 30}
|
||||
assert call2["t_name"] == "D"
|
||||
assert call2["t_props"] == {"role": "User"}
|
||||
assert call2["r_type"] == "LIKES"
|
||||
assert call2["r_props"] == {"weight": 0.8}
|
||||
|
||||
print("Verification passed!")
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -364,7 +397,7 @@ defineExpose({
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--gray-0);
|
||||
// background-color: var(--gray-0);
|
||||
|
||||
.graph-canvas {
|
||||
width: 100%;
|
||||
|
||||
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: absolute; // 改为 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>
|
||||
@ -12,11 +12,7 @@
|
||||
<span v-if="graphInfo?.relationship_count" class="info-total">/ {{ graphInfo.relationship_count }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="graphInfo?.graph_name" class="info-item">
|
||||
<span class="info-label">图谱</span>
|
||||
<span class="info-value">{{ graphInfo.graph_name }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="unindexedCount > 0" class="info-item warning">
|
||||
<span class="info-label">未索引</span>
|
||||
<span class="info-value warning">{{ unindexedCount }}</span>
|
||||
@ -35,17 +31,10 @@
|
||||
</a-tooltip>
|
||||
</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'
|
||||
|
||||
const props = defineProps({
|
||||
graphInfo: {
|
||||
@ -66,7 +55,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['index-nodes', 'export-data'])
|
||||
defineEmits(['index-nodes'])
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -120,40 +109,6 @@ defineEmits(['index-nodes', 'export-data'])
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
|
||||
@ -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
74
web/src/components/LightRAGInfoPanel.vue
Normal file
74
web/src/components/LightRAGInfoPanel.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
stats: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
graphData: {
|
||||
type: Object,
|
||||
default: () => ({ nodes: [], edges: [] })
|
||||
},
|
||||
databaseName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
</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;
|
||||
}
|
||||
</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,14 +13,31 @@
|
||||
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 type="primary" @click="state.showModal = true" ><UploadOutlined/> 上传文件</a-button>
|
||||
</a-button> -->
|
||||
<a-button
|
||||
v-if="isNeo4j"
|
||||
type="primary"
|
||||
@click="state.showModal = true" ><UploadOutlined/> 上传文件</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="state.showUploadTipModal = true" ><UploadOutlined/> 上传文件</a-button>
|
||||
<a-button v-if="unindexedCount > 0" type="primary" @click="indexNodes" :loading="state.indexing">
|
||||
<SyncOutlined v-if="!state.indexing"/> 为{{ unindexedCount }}个节点添加索引
|
||||
</a-button>
|
||||
@ -30,15 +47,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 +67,62 @@
|
||||
<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="exportGraphData" :icon="h(ExportOutlined)">
|
||||
导出数据
|
||||
</a-button>
|
||||
<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()"
|
||||
/>
|
||||
</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"
|
||||
@ -114,6 +152,32 @@
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 上传提示弹窗 -->
|
||||
<a-modal
|
||||
:open="state.showUploadTipModal"
|
||||
title="文件上传说明"
|
||||
@cancel="() => state.showUploadTipModal = false"
|
||||
:footer="null"
|
||||
width="500px"
|
||||
>
|
||||
<div class="upload-tip-content">
|
||||
<a-alert
|
||||
:message="getUploadTipMessage()"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
<div v-if="!isNeo4j" class="upload-tip-actions">
|
||||
<p>如需上传文档到当前选中的知识库,请前往对应的知识库详情页面进行操作:</p>
|
||||
<div class="action-buttons">
|
||||
<a-button type="primary" @click="goToDatabasePage">
|
||||
<DatabaseOutlined/> 前往知识库详情页
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 说明弹窗 -->
|
||||
<a-modal
|
||||
:open="state.showInfoModal"
|
||||
@ -122,7 +186,7 @@
|
||||
:footer="null"
|
||||
width="600px"
|
||||
>
|
||||
<div class="info-content">
|
||||
<div class="info-content" v-if="isNeo4j">
|
||||
<p>本页面展示的是 Neo4j 图数据库中的知识图谱信息。</p>
|
||||
<p>具体展示内容包括:</p>
|
||||
<ul>
|
||||
@ -132,15 +196,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>
|
||||
@ -148,45 +210,103 @@ LIMIT $num</code></pre>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, h } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
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 { UploadOutlined, SyncOutlined, GlobalOutlined, InfoCircleOutlined, SearchOutlined, ReloadOutlined, LoadingOutlined, HighlightOutlined, DatabaseOutlined, ExportOutlined } 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);
|
||||
const modelMatched = computed(() => !graphInfo?.value?.embed_model_name || graphInfo.value.embed_model_name === cur_embed_model.value)
|
||||
|
||||
const router = useRouter();
|
||||
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,
|
||||
showInfoModal: false,
|
||||
showUploadTipModal: false,
|
||||
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 +342,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 +368,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 +403,9 @@ const onSearch = () => {
|
||||
.finally(() => state.searchLoading = false)
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadGraphInfo();
|
||||
onMounted(async () => {
|
||||
await loadDatabases();
|
||||
loadGraphInfo(); // Load default (Neo4j) info
|
||||
loadSampleNodes();
|
||||
});
|
||||
|
||||
@ -332,9 +461,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 +472,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 +490,38 @@ 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;
|
||||
};
|
||||
|
||||
const getUploadTipMessage = () => {
|
||||
if (isNeo4j.value) {
|
||||
return 'Neo4j 图数据库支持通过上传 JSONL 格式文件直接导入实体和关系数据。';
|
||||
} else {
|
||||
const selectedDb = state.dbOptions.find(db => db.value === state.selectedDbId);
|
||||
const dbType = selectedDb?.type || '未知';
|
||||
const dbName = selectedDb?.label || getDatabaseName();
|
||||
return `当前选择的是 ${dbType.toUpperCase()} 类型的知识库"${dbName}",该类型知识库需要在文档知识库页面上传文档,系统会自动从中提取知识图谱。`;
|
||||
}
|
||||
};
|
||||
|
||||
const goToDatabasePage = () => {
|
||||
state.showUploadTipModal = false;
|
||||
|
||||
// 如果不是 Neo4j,需要找到对应的知识库 ID 并跳转
|
||||
if (!isNeo4j.value) {
|
||||
const selectedDb = state.dbOptions.find(db => db.value === state.selectedDbId);
|
||||
if (selectedDb && selectedDb.type !== 'neo4j') {
|
||||
// 跳转到对应的知识库详情页面
|
||||
router.push(`/database/${state.selectedDbId}`);
|
||||
} else {
|
||||
// 如果找不到对应的数据库,跳转到数据库列表页面
|
||||
router.push('/database');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -374,6 +536,17 @@ const openLink = (url) => {
|
||||
}
|
||||
}
|
||||
|
||||
.db-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 20px;
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -469,4 +642,19 @@ const openLink = (url) => {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.upload-tip-content {
|
||||
.upload-tip-actions {
|
||||
p {
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user