diff --git a/docker-compose.yml b/docker-compose.yml index 5cfd58be..f2edd849 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 766b4bf2..da6c99fb 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -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 diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 017bb2b5..b8f2c610 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -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) diff --git a/server/routers/graph_router.py b/server/routers/graph_router.py new file mode 100644 index 00000000..88972f42 --- /dev/null +++ b/server/routers/graph_router.py @@ -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)}") \ No newline at end of file diff --git a/web/package.json b/web/package.json index 02915579..bf91194a 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/src/apis/graph_api.js b/web/src/apis/graph_api.js new file mode 100644 index 00000000..0e490ca3 --- /dev/null +++ b/web/src/apis/graph_api.js @@ -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) +} \ No newline at end of file diff --git a/web/src/assets/base.css b/web/src/assets/css/base.css similarity index 100% rename from web/src/assets/base.css rename to web/src/assets/css/base.css diff --git a/web/src/assets/main.css b/web/src/assets/css/main.css similarity index 90% rename from web/src/assets/main.css rename to web/src/assets/css/main.css index 7553fcab..d24ccb5c 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/css/main.css @@ -1,4 +1,6 @@ @import './base.css'; +@import './sigma.css'; +@import './shorts.css'; :root { --header-height: 60px; diff --git a/web/src/assets/css/shorts.css b/web/src/assets/css/shorts.css new file mode 100644 index 00000000..49b1db5d --- /dev/null +++ b/web/src/assets/css/shorts.css @@ -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; +} \ No newline at end of file diff --git a/web/src/assets/css/sigma.css b/web/src/assets/css/sigma.css new file mode 100644 index 00000000..ec73888e --- /dev/null +++ b/web/src/assets/css/sigma.css @@ -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; + } +} \ No newline at end of file diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index ee30cc8c..35e42875 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -1317,7 +1317,7 @@ const mergeMessageChunk = (chunks) => { \ No newline at end of file diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index 9555da41..619556fb 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -207,7 +207,7 @@ const mainList = [{