feat: 增加 LightRAG的图谱可视化功能(但是目前的检索和可视化会出现跨知识库的情况,需要继续debug)

This commit is contained in:
Wenjie Zhang 2025-06-30 22:29:23 +08:00
parent c89ba37617
commit 8f37e1c8d9
17 changed files with 2502 additions and 32 deletions

View File

@ -6,7 +6,7 @@ services:
args:
http_proxy: ${HTTP_PROXY:-}
https_proxy: ${HTTPS_PROXY:-}
image: yuxi-api:0.1.0
image: yuxi-api:0.2.0.dev
container_name: api-dev
working_dir: /app
volumes:
@ -68,7 +68,7 @@ services:
context: .
dockerfile: docker/web.Dockerfile
target: development
image: yuxi-web:0.1.0
image: yuxi-web:0.2.0.dev
container_name: web-dev
volumes:
- ./web:/app

View File

@ -1,21 +1,25 @@
# 开发阶段
FROM node:latest AS development
FROM node:20-alpine AS development
WORKDIR /app
ARG http_proxy
ARG https_proxy
ENV http_proxy=$http_proxy \
https_proxy=$https_proxy \
TZ=Asia/Shanghai
ENV TZ=Asia/Shanghai
# 复制 package.json 和 package-lock.json如果存在
# 只有当代理变量不为空时才设置代理
RUN if [ -n "$http_proxy" ]; then echo "export http_proxy=$http_proxy" >> /etc/environment; fi
RUN if [ -n "$https_proxy" ]; then echo "export https_proxy=$https_proxy" >> /etc/environment; fi
# 安装 pnpm
RUN npm install -g pnpm@latest
# 复制 package.json 和 pnpm-lock.yaml
COPY ./web/package*.json ./
COPY ./web/pnpm-lock.yaml* ./
# 安装依赖
RUN npm install -g pnpm@latest-10
RUN pnpm install
# RUN npm install --registry http://mirrors.cloud.tencent.com/npm/ --verbose --force
# 复制源代码
COPY ./web .
@ -26,15 +30,22 @@ EXPOSE 5173
# 启动开发服务器的命令在 docker-compose 文件中定义
# 生产阶段
FROM node:latest AS build-stage
FROM node:20-alpine AS build-stage
WORKDIR /app
COPY ./web/package*.json ./
RUN npm install --force
# RUN npm install --registry https://registry.npmmirror.com --force
# 安装 pnpm
RUN npm install -g pnpm@latest
# 复制依赖文件
COPY ./web/package*.json ./
COPY ./web/pnpm-lock.yaml* ./
# 安装依赖
RUN pnpm install --frozen-lockfile
# 复制源代码并构建
COPY ./web .
RUN npm run build
RUN pnpm run build
# 生产环境运行阶段
FROM nginx:alpine AS production

View File

@ -3,9 +3,11 @@ from server.routers.chat_router import chat
from server.routers.data_router import data
from server.routers.base_router import base
from server.routers.auth_router import auth
from server.routers.graph_router import graph
router = APIRouter()
router.include_router(base)
router.include_router(chat)
router.include_router(data)
router.include_router(auth)
router.include_router(graph)

View File

@ -0,0 +1,258 @@
import json
import os
import asyncio
import traceback
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Query, HTTPException, Depends
from server.utils.auth_middleware import get_admin_user
from server.models.user_model import User
from src import knowledge_base
from src.utils.logging_config import logger
graph = APIRouter()
@graph.get("/graph/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)
):
"""
使用 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 实例
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail=f"数据库 {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 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("/graph/labels")
async def get_graph_labels(
db_id: str = Query(..., description="数据库ID"),
current_user: User = Depends(get_admin_user)
):
"""
获取知识图谱中的所有标签
Args:
db_id: LightRAG 数据库实例ID
Returns:
图谱中所有可用的标签列表
"""
try:
logger.info(f"获取图谱标签 - db_id: {db_id}")
# 获取 LightRAG 实例
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail=f"数据库 {db_id} 不存在")
# 使用 LightRAG 的原生方法获取所有标签
labels = await rag_instance.get_graph_labels()
return {
"success": True,
"data": {
"labels": labels
}
}
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("/graph/databases")
async def get_available_databases(
current_user: User = Depends(get_admin_user)
):
"""
获取所有可用的 LightRAG 数据库
Returns:
可用的数据库列表
"""
try:
databases = knowledge_base.get_databases()
return {
"success": True,
"data": databases
}
except Exception as e:
logger.error(f"获取数据库列表失败: {e}")
raise HTTPException(status_code=500, detail=f"获取数据库列表失败: {str(e)}")
# 保留原有的直接数据库查询方法作为备用(如果需要的话)
@graph.get("/graph/nodes")
async def get_graph_nodes_legacy(
db_id: str = Query(..., description="数据库ID"),
limit: int = Query(500, description="最大节点数量", ge=1, le=2000),
offset: int = Query(0, description="偏移量", ge=0),
entity_type: Optional[str] = Query(None, description="实体类型筛选"),
search: Optional[str] = Query(None, description="搜索关键词"),
current_user: User = Depends(get_admin_user)
):
"""
直接查询数据库获取节点数据备用方法
建议使用 /graph/subgraph 接口
"""
try:
# 这里可以添加直接数据库查询的逻辑
# 但建议用户使用 get_subgraph 接口
return {
"success": False,
"message": "建议使用 /graph/subgraph 接口获取图谱数据",
"data": {
"nodes": [],
"total": 0
}
}
except Exception as e:
logger.error(f"获取图节点数据失败: {e}")
raise HTTPException(status_code=500, detail=f"获取图节点数据失败: {str(e)}")
@graph.get("/graph/edges")
async def get_graph_edges_legacy(
db_id: str = Query(..., description="数据库ID"),
limit: int = Query(500, description="最大边数量", ge=1, le=2000),
offset: int = Query(0, description="偏移量", ge=0),
min_weight: Optional[float] = Query(None, description="最小权重筛选"),
current_user: User = Depends(get_admin_user)
):
"""
直接查询数据库获取边数据备用方法
建议使用 /graph/subgraph 接口
"""
try:
# 这里可以添加直接数据库查询的逻辑
# 但建议用户使用 get_subgraph 接口
return {
"success": False,
"message": "建议使用 /graph/subgraph 接口获取图谱数据",
"data": {
"edges": [],
"total": 0
}
}
except Exception as e:
logger.error(f"获取图边数据失败: {e}")
raise HTTPException(status_code=500, detail=f"获取图边数据失败: {str(e)}")
@graph.get("/graph/stats")
async def get_graph_stats(
db_id: str = Query(..., description="数据库ID"),
current_user: User = Depends(get_admin_user)
):
"""
获取知识图谱统计信息
"""
try:
logger.info(f"获取图谱统计信息 - db_id: {db_id}")
# 获取 LightRAG 实例
rag_instance = await knowledge_base._get_lightrag_instance(db_id)
if not rag_instance:
raise HTTPException(status_code=404, detail=f"数据库 {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 Exception as e:
logger.error(f"获取图谱统计信息失败: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取图谱统计信息失败: {str(e)}")

View File

@ -1,6 +1,6 @@
{
"name": "yuxi-know-frontend",
"version": "0.0.0",
"version": "0.2.0.dev",
"private": true,
"scripts": {
"dev": "vite",
@ -14,6 +14,8 @@
"dependencies": {
"@ant-design/icons-vue": "^6.1.0",
"@antv/g6": "^5.0.47",
"@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0",
"@vueuse/core": "^13.2.0",
"ant-design-vue": "^4.2.6",
"axios": "^1.9.0",
@ -21,6 +23,8 @@
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"echarts-gl": "^2.0.9",
"graphology": "^0.26.0",
"graphology-generators": "^0.11.2",
"highlight.js": "^11.11.1",
"less": "^4.3.0",
"lucide-vue-next": "^0.511.0",
@ -28,6 +32,7 @@
"marked-highlight": "^2.2.1",
"md-editor-v3": "^5.5.1",
"pinia": "^3.0.2",
"sigma": "^3.0.1",
"vue": "^3.5.14",
"vue-router": "^4.5.1"
},

238
web/src/apis/graph_api.js Normal file
View File

@ -0,0 +1,238 @@
import { apiGet } from './base'
/**
* 图数据API调用 - 基于LightRAG的新接口
*/
/**
* 获取所有可用的数据库
* @returns {Promise} - 数据库列表
*/
export const getAvailableDatabases = async () => {
return await apiGet('/api/graph/databases', {}, true)
}
/**
* 获取图标签列表
* @param {string} dbId - 数据库ID
* @returns {Promise} - 标签列表
*/
export const getGraphLabels = async (dbId) => {
if (!dbId) {
throw new Error('db_id is required')
}
const queryParams = new URLSearchParams({
db_id: dbId
})
return await apiGet(`/api/graph/labels?${queryParams.toString()}`, {}, true)
}
/**
* 获取子图数据 - 主要接口
* @param {Object} params - 查询参数
* @param {string} params.db_id - 数据库ID
* @param {string} params.node_label - 节点标签 (使用 "*" 获取全图)
* @param {number} params.max_depth - 最大深度
* @param {number} params.max_nodes - 最大节点数
* @returns {Promise} - 子图数据
*/
export const getSubgraph = async (params) => {
const { db_id, node_label = "*", max_depth = 2, max_nodes = 100 } = params
if (!db_id) {
throw new Error('db_id is required')
}
const queryParams = new URLSearchParams({
db_id: db_id,
node_label: node_label,
max_depth: max_depth.toString(),
max_nodes: max_nodes.toString()
})
return await apiGet(`/api/graph/subgraph?${queryParams.toString()}`, {}, true)
}
/**
* 获取图统计信息
* @param {string} dbId - 数据库ID
* @returns {Promise} - 统计数据
*/
export const getGraphStats = async (dbId) => {
if (!dbId) {
throw new Error('db_id is required')
}
const queryParams = new URLSearchParams({
db_id: dbId
})
return await apiGet(`/api/graph/stats?${queryParams.toString()}`, {}, true)
}
/**
* 获取完整图数据 - 使用新的子图接口
* @param {Object} params - 查询参数
* @param {string} params.db_id - 数据库ID
* @param {string} params.node_label - 节点标签筛选
* @param {number} params.max_nodes - 最大节点数
* @param {number} params.max_depth - 最大深度
* @returns {Promise} - 完整图数据
*/
export const getFullGraph = async (params = {}) => {
const { db_id, node_label = "*", max_nodes = 200, max_depth = 2 } = params
if (!db_id) {
throw new Error('db_id is required for graph operations')
}
try {
// 使用子图接口获取数据
const response = await getSubgraph({
db_id,
node_label,
max_nodes,
max_depth
})
if (!response.success) {
throw new Error('获取图数据失败')
}
return {
success: true,
data: {
nodes: response.data.nodes,
edges: response.data.edges,
is_truncated: response.data.is_truncated,
stats: {
total_nodes: response.data.total_nodes,
total_edges: response.data.total_edges,
displayed_nodes: response.data.nodes.length,
displayed_edges: response.data.edges.length
}
}
}
} catch (error) {
console.error('获取完整图数据失败:', error)
throw error
}
}
/**
* 根据特定标签获取子图
* @param {Object} params - 查询参数
* @param {string} params.db_id - 数据库ID
* @param {string} params.entity_type - 实体类型
* @param {number} params.max_nodes - 最大节点数
* @param {number} params.max_depth - 最大深度
* @returns {Promise} - 子图数据
*/
export const getGraphByEntityType = async (params = {}) => {
const { db_id, entity_type, max_nodes = 100, max_depth = 2 } = params
if (!db_id) {
throw new Error('db_id is required')
}
return await getSubgraph({
db_id,
node_label: entity_type || "*",
max_nodes,
max_depth
})
}
/**
* 展开指定节点的邻居
* @param {Object} params - 查询参数
* @param {string} params.db_id - 数据库ID
* @param {string} params.node_label - 节点标签
* @param {number} params.max_depth - 最大深度
* @param {number} params.max_nodes - 最大节点数
* @returns {Promise} - 邻居节点数据
*/
export const expandNodeNeighbors = async (params) => {
const { db_id, node_label, max_depth = 1, max_nodes = 50 } = params
if (!db_id || !node_label) {
throw new Error('db_id and node_label are required')
}
return await getSubgraph({
db_id,
node_label,
max_depth,
max_nodes
})
}
// ==================== 兼容性方法 ====================
/**
* 获取图节点数据 (已弃用建议使用 getSubgraph)
* @deprecated 请使用 getSubgraph 替代
*/
export const getGraphNodes = async (params = {}) => {
console.warn('getGraphNodes is deprecated, please use getSubgraph instead')
const { db_id } = params
if (!db_id) {
throw new Error('db_id is required. Please provide db_id parameter.')
}
return await getSubgraph({ ...params, node_label: "*" })
}
/**
* 获取图边数据 (已弃用建议使用 getSubgraph)
* @deprecated 请使用 getSubgraph 替代
*/
export const getGraphEdges = async (params = {}) => {
console.warn('getGraphEdges is deprecated, please use getSubgraph instead')
const { db_id } = params
if (!db_id) {
throw new Error('db_id is required. Please provide db_id parameter.')
}
return await getSubgraph({ ...params, node_label: "*" })
}
// ==================== 工具函数 ====================
/**
* 根据实体类型获取颜色
* @param {string} entityType - 实体类型
* @returns {string} - 颜色值
*/
export const getEntityTypeColor = (entityType) => {
const colorMap = {
'person': '#FF6B6B', // 红色 - 人物
'organization': '#4ECDC4', // 青色 - 组织
'location': '#45B7D1', // 蓝色 - 地点
'geo': '#45B7D1', // 蓝色 - 地理位置
'event': '#96CEB4', // 绿色 - 事件
'category': '#FFEAA7', // 黄色 - 分类
'equipment': '#DDA0DD', // 紫色 - 设备
'athlete': '#FF7675', // 红色 - 运动员
'record': '#FD79A8', // 粉色 - 记录
'year': '#FDCB6E', // 橙色 - 年份
'UNKNOWN': '#B2BEC3', // 灰色 - 未知
'unknown': '#B2BEC3' // 灰色 - 未知
}
return colorMap[entityType] || colorMap['unknown']
}
/**
* 根据权重计算边的粗细
* @param {number} weight - 权重值
* @param {number} minWeight - 最小权重
* @param {number} maxWeight - 最大权重
* @returns {number} - 边的粗细
*/
export const calculateEdgeWidth = (weight, minWeight = 1, maxWeight = 10) => {
const minWidth = 1
const maxWidth = 5
const normalizedWeight = (weight - minWeight) / (maxWeight - minWeight)
return minWidth + normalizedWeight * (maxWidth - minWidth)
}

View File

@ -1,4 +1,6 @@
@import './base.css';
@import './sigma.css';
@import './shorts.css';
:root {
--header-height: 60px;

View File

@ -0,0 +1,46 @@
.m-2 {
margin: 8px;
}
.mt-2 {
margin-top: 8px;
}
.mb-2 {
margin-bottom: 8px;
}
.ml-2 {
margin-left: 8px;
}
.mr-2 {
margin-right: 8px;
}
.m-3 {
margin: 12px;
}
.mt-3 {
margin-top: 12px;
}
.mb-3 {
margin-bottom: 12px;
}
.ml-3 {
margin-left: 12px;
}
.mr-3 {
margin-right: 12px;
}
.bn-1px {
position: relative;
bottom: -1px;
}

View File

@ -0,0 +1,88 @@
/* Sigma.js 基础样式 */
.sigma-container {
position: relative;
width: 100%;
height: 100%;
background: #fafafa;
}
.sigma-container canvas {
outline: none;
}
/* 节点标签样式 */
.sigma-labels {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* 高亮样式 */
.node-highlighted {
stroke: #ff0000 !important;
stroke-width: 3px !important;
}
.edge-highlighted {
stroke: #ff0000 !important;
stroke-width: 3px !important;
}
/* 选中样式 */
.node-selected {
stroke: #1890ff !important;
stroke-width: 4px !important;
}
.edge-selected {
stroke: #1890ff !important;
stroke-width: 4px !important;
}
/* 悬停样式 */
.node-focused {
stroke: #52c41a !important;
stroke-width: 2px !important;
}
.edge-focused {
stroke: #52c41a !important;
stroke-width: 2px !important;
}
/* 加载动画 */
.sigma-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
}
/* 工具提示 */
.sigma-tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
/* 响应式调整 */
@media (max-width: 768px) {
.sigma-container {
font-size: 10px;
}
.sigma-tooltip {
font-size: 10px;
padding: 6px 8px;
}
}

View File

@ -1317,7 +1317,7 @@ const mergeMessageChunk = (chunks) => {
</script>
<style lang="less" scoped>
@import '@/assets/main.css';
@import '@/assets/css/main.css';
.chat-container {
display: flex;

View File

@ -1,6 +1,9 @@
<template>
<div class="header-container">
<div class="header-content">
<div class="header-actions" v-if="$slots.left">
<slot name="left"></slot>
</div>
<div class="header-title">
<h1>{{ title }}</h1>
<slot name="description">
@ -48,9 +51,12 @@ const props = defineProps({
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.header-title {
flex: 1;
width: 100%;
font-size: 14px;
color: rgba(0, 0, 0, 0.45);

File diff suppressed because it is too large Load Diff

View File

@ -207,7 +207,7 @@ const mainList = [{
</template>
<style lang="less" scoped>
@import '@/assets/main.css';
@import '@/assets/css/main.css';
:root {
--header-width: 60px;

View File

@ -6,7 +6,7 @@ import router from './router'
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import './assets/main.css'
import '@/assets/css/main.css'
const app = createApp(App)
const pinia = createPinia()

View File

@ -0,0 +1,397 @@
import { defineStore } from 'pinia'
import { DirectedGraph } from 'graphology'
export const useGraphStore = defineStore('graph', {
state: () => ({
// 选中和聚焦状态
selectedNode: null,
focusedNode: null,
selectedEdge: null,
focusedEdge: null,
// 图数据
rawGraph: null,
sigmaGraph: null,
sigmaInstance: null,
// 实体类型和颜色映射
entityTypes: [],
typeColorMap: new Map(),
// 加载状态
isFetching: false,
graphIsEmpty: false,
// 移动到选中节点的标志
moveToSelectedNode: false,
// 图统计信息
stats: {
total_nodes: 0,
total_edges: 0,
displayed_nodes: 0,
displayed_edges: 0
}
}),
getters: {
// 获取选中节点的详细信息
selectedNodeData: (state) => {
if (!state.selectedNode || !state.rawGraph) return null
return state.rawGraph.nodes.find(node => node.id === state.selectedNode)
},
// 获取选中边的详细信息
selectedEdgeData: (state) => {
if (!state.selectedEdge || !state.rawGraph) return null
// 首先尝试通过dynamicId匹配
let foundEdge = state.rawGraph.edges.find(edge => edge.dynamicId === state.selectedEdge)
if (foundEdge) return foundEdge
// 如果没找到尝试通过原始ID匹配
foundEdge = state.rawGraph.edges.find(edge => edge.id === state.selectedEdge)
if (foundEdge) return foundEdge
// 最后尝试通过source->target格式匹配
const [source, target] = state.selectedEdge.split('->')
if (source && target) {
foundEdge = state.rawGraph.edges.find(edge =>
edge.source === source.trim() && edge.target === target.trim()
)
}
return foundEdge || null
},
// 检查图是否为空
isGraphEmpty: (state) => {
return !state.rawGraph || state.rawGraph.nodes.length === 0
}
},
actions: {
// 设置Sigma实例
setSigmaInstance(instance) {
this.sigmaInstance = instance
},
// 节点选择和聚焦
setSelectedNode(nodeId, moveToNode = false) {
this.selectedNode = nodeId
this.moveToSelectedNode = moveToNode
// 如果选中节点,清除选中的边
if (nodeId) {
this.selectedEdge = null
}
},
setFocusedNode(nodeId) {
this.focusedNode = nodeId
},
setSelectedEdge(edgeId) {
this.selectedEdge = edgeId
// 如果选中边,清除选中的节点
if (edgeId) {
this.selectedNode = null
}
},
setFocusedEdge(edgeId) {
this.focusedEdge = edgeId
},
// 清除所有选择
clearSelection() {
this.selectedNode = null
this.focusedNode = null
this.selectedEdge = null
this.focusedEdge = null
},
// 设置加载状态
setIsFetching(isFetching) {
this.isFetching = isFetching
},
// 设置实体类型
setEntityTypes(types) {
this.entityTypes = types
this.updateTypeColorMap()
},
// 更新类型颜色映射
updateTypeColorMap() {
const colorPalette = [
'#FF6B6B', // 人物 - 红色
'#4ECDC4', // 组织 - 青色
'#45B7D1', // 地点 - 蓝色
'#96CEB4', // 事件 - 绿色
'#FFEAA7', // 分类 - 黄色
'#DDA0DD', // 设备 - 紫色
'#FF7675', // 运动员 - 红色
'#FD79A8', // 记录 - 粉色
'#FDCB6E', // 年份 - 橙色
'#B2BEC3' // 未知 - 灰色
]
const typeColorMap = new Map()
this.entityTypes.forEach((type, index) => {
const colorIndex = index % colorPalette.length
typeColorMap.set(type.type, colorPalette[colorIndex])
})
// 为特殊类型设置固定颜色
typeColorMap.set('person', '#FF6B6B')
typeColorMap.set('organization', '#4ECDC4')
typeColorMap.set('location', '#45B7D1')
typeColorMap.set('geo', '#45B7D1')
typeColorMap.set('event', '#96CEB4')
typeColorMap.set('category', '#FFEAA7')
typeColorMap.set('unknown', '#B2BEC3')
this.typeColorMap = typeColorMap
},
// 获取实体类型颜色
getEntityColor(entityType) {
return this.typeColorMap.get(entityType) || '#B2BEC3'
},
// 设置原始图数据
setRawGraph(rawGraph) {
this.rawGraph = rawGraph
this.graphIsEmpty = !rawGraph || rawGraph.nodes.length === 0
this.updateStats()
},
// 设置Sigma图数据
setSigmaGraph(sigmaGraph) {
this.sigmaGraph = sigmaGraph
},
// 更新统计信息
updateStats() {
if (this.rawGraph) {
this.stats = {
total_nodes: this.rawGraph.nodes.length,
total_edges: this.rawGraph.edges.length,
displayed_nodes: this.rawGraph.nodes.length,
displayed_edges: this.rawGraph.edges.length
}
}
},
// 从API数据创建图数据结构
createGraphFromApiData(nodesData, edgesData) {
const rawGraph = {
nodes: [],
edges: [],
nodeIdMap: {},
edgeIdMap: {},
edgeDynamicIdMap: {}
}
console.log('Processing nodes data:', nodesData)
// 处理节点数据
nodesData.forEach((node, index) => {
// 适配新的LightRAG API格式
const nodeId = String(node.id)
const labels = node.labels || [node.entity_type || 'unknown']
const entityType = node.entity_type || labels[0] || 'unknown'
const processedNode = {
id: nodeId,
labels: Array.isArray(labels) ? labels.map(String) : [String(labels)],
entity_type: String(entityType),
properties: {
entity_id: String(node.properties?.entity_id || node.entity_id || nodeId),
entity_type: String(entityType),
description: String(node.properties?.description || node.description || ''),
file_path: String(node.properties?.file_path || node.file_path || ''),
...(node.properties || {})
},
// Sigma.js需要的属性
size: this.calculateNodeSize(node),
x: Math.random() * 1000, // 随机初始位置
y: Math.random() * 1000,
color: this.getEntityColor(String(entityType)),
degree: 0 // 将在处理边时计算
}
rawGraph.nodes.push(processedNode)
rawGraph.nodeIdMap[nodeId] = index
})
// 计算节点度数
const nodeDegrees = {}
edgesData.forEach(edge => {
const sourceId = String(edge.source)
const targetId = String(edge.target)
nodeDegrees[sourceId] = (nodeDegrees[sourceId] || 0) + 1
nodeDegrees[targetId] = (nodeDegrees[targetId] || 0) + 1
})
// 更新节点度数和大小
rawGraph.nodes.forEach(node => {
node.degree = nodeDegrees[node.id] || 0
node.size = this.calculateNodeSize({ degree: node.degree })
})
console.log('Processing edges data:', edgesData)
// 处理边数据
edgesData.forEach((edge, index) => {
const sourceId = String(edge.source)
const targetId = String(edge.target)
const dynamicId = `${sourceId}-${targetId}-${index}`
// 适配新的LightRAG API格式
const weight = Number(edge.properties?.weight || edge.weight || 1.0)
const processedEdge = {
id: String(edge.id),
source: sourceId,
target: targetId,
type: edge.type || 'DIRECTED',
properties: {
weight: weight,
keywords: String(edge.properties?.keywords || edge.keywords || ''),
description: String(edge.properties?.description || edge.description || ''),
file_path: String(edge.properties?.file_path || edge.file_path || ''),
...(edge.properties || {})
},
dynamicId: dynamicId,
// Sigma.js需要的属性
size: this.calculateEdgeSize(weight),
color: '#666',
originalWeight: weight
}
rawGraph.edges.push(processedEdge)
rawGraph.edgeIdMap[edge.id] = index
rawGraph.edgeDynamicIdMap[dynamicId] = index
})
return rawGraph
},
// 计算节点大小
calculateNodeSize(node) {
const baseSizeM = 15
const degree = node.degree || 0
return Math.min(baseSizeM + degree * 3, 40)
},
// 计算边大小
calculateEdgeSize(weight) {
const minSize = 3 // 与Sigma配置中的minEdgeThickness保持一致
const maxSize = 8 // 与Sigma配置中的maxEdgeThickness保持一致
const normalizedWeight = Math.max(0, Math.min(1, (weight - 1) / 9)) // 假设权重范围1-10
return minSize + normalizedWeight * (maxSize - minSize)
},
// 创建Sigma图实例
createSigmaGraph(rawGraph) {
console.log('开始创建Sigma图节点数量:', rawGraph.nodes.length, '边数量:', rawGraph.edges.length)
const sigmaGraph = new DirectedGraph()
// 添加节点
rawGraph.nodes.forEach(node => {
// 确保所有属性都是正确的类型
const nodeAttributes = {
label: String(node.properties?.entity_id || node.id),
size: Number(node.size) || 15,
color: String(node.color) || '#B2BEC3',
x: Number(node.x) || Math.random() * 1000,
y: Number(node.y) || Math.random() * 1000,
// 保存原始数据引用
originalData: node
}
sigmaGraph.addNode(String(node.id), nodeAttributes)
})
console.log('节点添加完成,开始添加边...')
// 添加边
let edgeAddedCount = 0
let edgeSkippedCount = 0
rawGraph.edges.forEach((edge, index) => {
// 添加调试信息
if (index < 3) {
console.log('处理边 #' + index + ':', {
id: edge.id,
source: edge.source,
target: edge.target,
dynamicId: edge.dynamicId,
edgeObject: edge
})
}
if (sigmaGraph.hasNode(String(edge.source)) && sigmaGraph.hasNode(String(edge.target))) {
// 确保所有属性都是正确的类型
const edgeAttributes = {
size: Number(edge.size) || 1,
color: String(edge.color) || '#666',
label: String(edge.properties?.keywords || edge.properties?.description || ''),
originalWeight: Number(edge.originalWeight) || 1,
// 保存原始数据引用
originalData: edge
}
// 使用标准的 addEdge 方法addEdge(edgeId, source, target, attributes)
try {
// 使用动态ID作为Sigma边ID避免重复
const sigmaEdgeId = edge.dynamicId || `${edge.source}->${edge.target}`
// 检查是否已存在相同的边
if (!sigmaGraph.hasEdge(sigmaEdgeId)) {
sigmaGraph.addEdgeWithKey(sigmaEdgeId, String(edge.source), String(edge.target), edgeAttributes)
edgeAddedCount++
} else {
edgeSkippedCount++
}
} catch (err) {
console.warn('添加边失败:', {
source: edge.source,
target: edge.target,
attributes: edgeAttributes,
error: err.message
})
}
} else {
console.warn('节点不存在,跳过边:', {
source: edge.source,
target: edge.target,
hasSource: sigmaGraph.hasNode(String(edge.source)),
hasTarget: sigmaGraph.hasNode(String(edge.target))
})
}
})
console.log(`边添加完成: 成功 ${edgeAddedCount}, 跳过 ${edgeSkippedCount}`)
return sigmaGraph
},
// 重置所有状态
reset() {
this.selectedNode = null
this.focusedNode = null
this.selectedEdge = null
this.focusedEdge = null
this.rawGraph = null
this.sigmaGraph = null
this.moveToSelectedNode = false
this.graphIsEmpty = false
this.stats = {
total_nodes: 0,
total_edges: 0,
displayed_nodes: 0,
displayed_edges: 0
}
}
}
})

View File

@ -4,18 +4,13 @@
:title="database.name || '数据库信息'"
:loading="state.databaseLoading"
>
<template #description>
<div class="database-info">
<a-tag color="blue" v-if="database.embed_model">{{ database.embed_model }}</a-tag>
<a-tag color="green" v-if="database.dimension">{{ database.dimension }}</a-tag>
<span class="row-count">{{ database.files ? Object.keys(database.files).length : 0 }} 文件 · {{ database.db_id }}</span>
</div>
<template #left>
<a-button type="text" @click="backToDatabase">
<LeftOutlined />
</a-button>
</template>
<template #actions>
<a-button type="primary" @click="backToDatabase">
<LeftOutlined /> 返回
</a-button>
<a-button type="primary" @click="showEditModal">
<a-button type="text" @click="showEditModal">
<EditOutlined />
</a-button>
</template>
@ -172,6 +167,12 @@
</a-button>
</div>
</div>
<!-- 数据库信息 -->
<div class="database-info">
<a-tag color="blue" v-if="database.embed_model">{{ database.embed_model }}</a-tag>
<a-tag color="green" v-if="database.dimension">{{ database.dimension }}</a-tag>
<span class="row-count">{{ database.files ? Object.keys(database.files).length : 0 }} 文件 · {{ database.db_id }}</span>
</div>
<a-table
:columns="columns"
@ -310,6 +311,17 @@
</div>
</div>
</a-tab-pane>
<a-tab-pane key="knowledge-graph" force-render>
<template #tab><span><Waypoints size="14" class="mr-3 bn-1px" />知识图谱开发中</span></template>
<div class="knowledge-graph-container db-tab-container">
<KnowledgeGraphViewer
:initial-database-id="databaseId"
:hide-db-selector="true"
/>
</div>
</a-tab-pane>
<!-- <a-tab-pane key="3" tab="Tab 3">Content of Tab Pane 3</a-tab-pane> -->
<template #rightExtra>
<div class="auto-refresh-control">
@ -323,13 +335,12 @@
</template>
<script setup>
import { onMounted, reactive, ref, watch, toRaw, onUnmounted, computed } from 'vue';
import { onMounted, reactive, ref, watch, toRaw, onUnmounted, computed, h } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { useRoute, useRouter } from 'vue-router';
import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user'
import { knowledgeBaseApi } from '@/apis/admin_api'
import HeaderComponent from '@/components/HeaderComponent.vue';
import {
ReadOutlined,
LeftOutlined,
@ -346,8 +357,12 @@ import {
EditOutlined,
PlusOutlined,
SettingOutlined,
ApartmentOutlined,
} from '@ant-design/icons-vue'
import { h } from 'vue';
import HeaderComponent from '@/components/HeaderComponent.vue';
import KnowledgeGraphViewer from '@/components/KnowledgeGraphViewer.vue';
import { Waypoints } from 'lucide-vue-next';
const route = useRoute();
@ -1026,6 +1041,11 @@ const toggleAutoRefresh = (checked) => {
gap: 12px;
}
.knowledge-graph-container {
height: calc(100vh - 150px);
min-height: 600px;
}
.query-test-container {
display: flex;
flex-direction: row;
@ -1628,6 +1648,17 @@ const toggleAutoRefresh = (checked) => {
}
}
.knowledge-graph-container {
height: calc(100vh - 200px);
:deep(.knowledge-graph-viewer) {
height: 100%;
.sigma-container {
height: calc(100% - 80px);
}
}
}
</style>