diff --git a/dashboard/src/routes/resource/knowledge-graph.tsx b/dashboard/src/routes/resource/knowledge-graph.tsx deleted file mode 100644 index 22940f20..00000000 --- a/dashboard/src/routes/resource/knowledge-graph.tsx +++ /dev/null @@ -1,722 +0,0 @@ -import { useState, useCallback, useEffect, memo } from 'react' -import { useNavigate } from '@tanstack/react-router' -import ReactFlow, { - Controls, - Background, - BackgroundVariant, - MiniMap, - useNodesState, - useEdgesState, - Panel, - Handle, - Position, - type Node, - type Edge, - type NodeTypes, -} from 'reactflow' -import 'reactflow/dist/style.css' -import dagre from 'dagre' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Badge } from '@/components/ui/badge' -import { ScrollArea } from '@/components/ui/scroll-area' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - Search, - RefreshCw, - Info, - Database, - Network, - FileText, -} from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import { getKnowledgeGraph, getKnowledgeStats, searchKnowledgeNode, type KnowledgeNode, type KnowledgeEdge, type KnowledgeStats } from '@/lib/knowledge-api' -import { cn } from '@/lib/utils' - -// 自定义节点组件 - 实体节点 -const EntityNode = memo(({ data }: { data: { label: string; content: string } }) => { - return ( -
- -
- {data.label} -
- -
- ) -}) - -EntityNode.displayName = 'EntityNode' - -// 自定义节点组件 - 段落节点 -const ParagraphNode = memo(({ data }: { data: { label: string; content: string } }) => { - return ( -
- -
- {data.label} -
- -
- ) -}) - -ParagraphNode.displayName = 'ParagraphNode' - -const nodeTypes: NodeTypes = { - entity: EntityNode, - paragraph: ParagraphNode, -} - -// 使用 dagre 进行自动布局 -function calculateLayout(nodes: KnowledgeNode[], edges: KnowledgeEdge[]): { nodes: Node[]; edges: Edge[] } { - const dagreGraph = new dagre.graphlib.Graph() - dagreGraph.setDefaultEdgeLabel(() => ({})) - dagreGraph.setGraph({ rankdir: 'TB', ranksep: 100, nodesep: 80 }) - - const flowNodes: Node[] = [] - const flowEdges: Edge[] = [] - - // 设置节点到 dagre 图 - nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: 150, height: 50 }) - }) - - // 设置边到 dagre 图 - edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target) - }) - - // 执行布局计算 - dagre.layout(dagreGraph) - - // 获取布局后的节点位置 - nodes.forEach((node) => { - const nodeWithPosition = dagreGraph.node(node.id) - flowNodes.push({ - id: node.id, - type: node.type, - position: { - x: nodeWithPosition.x - 75, - y: nodeWithPosition.y - 25, - }, - data: { - label: node.content.slice(0, 20) + (node.content.length > 20 ? '...' : ''), - content: node.content, - }, - }) - }) - - // 创建边 - edges.forEach((edge, index) => { - const flowEdge: Edge = { - id: `edge-${index}`, - source: edge.source, - target: edge.target, - // 节点数超过200时禁用动画提升性能 - animated: nodes.length <= 200 && edge.weight > 5, - style: { - strokeWidth: Math.min(edge.weight / 2, 5), - opacity: 0.6, - }, - } - // 只在节点数少于100时显示边的标签 - if (edge.weight > 10 && nodes.length < 100) { - flowEdge.label = `${edge.weight.toFixed(0)}` - } - flowEdges.push(flowEdge) - }) - - return { nodes: flowNodes, edges: flowEdges } -} - -export function KnowledgeGraphPage() { - const navigate = useNavigate() - const [loading, setLoading] = useState(false) - const [stats, setStats] = useState(null) - const [searchQuery, setSearchQuery] = useState('') - const [nodeType, setNodeType] = useState<'all' | 'entity' | 'paragraph'>('all') - const [nodeLimit, setNodeLimit] = useState(50) - const [customLimit, setCustomLimit] = useState('50') - const [showCustomInput, setShowCustomInput] = useState(false) - const [showInitialConfirm, setShowInitialConfirm] = useState(true) - const [userConfirmedLoad, setUserConfirmedLoad] = useState(false) // 用户是否确认加载 - const [showHighNodeWarning, setShowHighNodeWarning] = useState(false) - const [nodes, setNodes, onNodesChange] = useNodesState([]) - const [edges, setEdges, onEdgesChange] = useEdgesState([]) - const [nodeCount, setNodeCount] = useState(0) - const [selectedNodeData, setSelectedNodeData] = useState(null) - const [selectedEdgeData, setSelectedEdgeData] = useState<{ source: KnowledgeNode; target: KnowledgeNode; edge: KnowledgeEdge } | null>(null) - const { toast } = useToast() - - // 缓存 MiniMap 的 nodeColor 函数 - const miniMapNodeColor = useCallback((node: Node) => { - if (node.type === 'entity') return '#6366f1' - if (node.type === 'paragraph') return '#10b981' - return '#6b7280' - }, []) - - // 加载知识图谱数据 - const loadGraph = useCallback(async (skipWarning = false) => { - try { - // 检查是否需要警告用户 - if (!skipWarning && nodeLimit > 200) { - setShowHighNodeWarning(true) - return - } - - setLoading(true) - const [graphData, statsData] = await Promise.all([ - getKnowledgeGraph(nodeLimit, nodeType), - getKnowledgeStats(), - ]) - - setStats(statsData) - - if (graphData.nodes.length === 0) { - toast({ - title: '提示', - description: '知识库为空,请先导入知识数据', - }) - setNodes([]) - setEdges([]) - return - } - - const { nodes: flowNodes, edges: flowEdges } = calculateLayout(graphData.nodes, graphData.edges) - setNodes(flowNodes) - setEdges(flowEdges) - setNodeCount(flowNodes.length) - - if (statsData && statsData.total_nodes > nodeLimit) { - toast({ - title: '提示', - description: `知识图谱包含 ${statsData.total_nodes} 个节点,当前显示 ${flowNodes.length} 个`, - }) - } - - toast({ - title: '加载成功', - description: `已加载 ${flowNodes.length} 个节点,${flowEdges.length} 条边`, - }) - } catch (error) { - console.error('加载知识图谱失败:', error) - toast({ - title: '加载失败', - description: error instanceof Error ? error.message : '未知错误', - variant: 'destructive', - }) - } finally { - setLoading(false) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeLimit, nodeType, toast]) // setNodes 和 setEdges 是稳定的,不需要包含 - - // 搜索节点 - const handleSearch = useCallback(async () => { - if (!searchQuery.trim()) { - toast({ - title: '提示', - description: '请输入搜索关键词', - }) - return - } - - try { - const results = await searchKnowledgeNode(searchQuery) - if (results.length === 0) { - toast({ - title: '未找到', - description: '没有找到匹配的节点', - }) - return - } - - // 高亮搜索结果 - const resultIds = new Set(results.map(r => r.id)) - setNodes(nds => - nds.map(node => ({ - ...node, - style: { - ...node.style, - opacity: resultIds.has(node.id) ? 1 : 0.3, - filter: resultIds.has(node.id) ? 'brightness(1.2)' : 'brightness(0.8)', - }, - })) - ) - - toast({ - title: '搜索完成', - description: `找到 ${results.length} 个匹配节点`, - }) - } catch (error) { - console.error('搜索失败:', error) - toast({ - title: '搜索失败', - description: error instanceof Error ? error.message : '未知错误', - variant: 'destructive', - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchQuery, toast]) // setNodes 是稳定的 - - // 重置高亮 - const handleResetHighlight = useCallback(() => { - setNodes(nds => - nds.map(node => ({ - ...node, - style: { - ...node.style, - opacity: 1, - filter: 'brightness(1)', - }, - })) - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) // setNodes 是稳定的 - - // 初始确认后加载 - const handleInitialConfirm = useCallback(() => { - setShowInitialConfirm(false) - setUserConfirmedLoad(true) // 设置用户确认标记 - loadGraph() - }, [loadGraph]) - - // 高节点数确认后加载 - const handleHighNodeConfirm = useCallback(() => { - setShowHighNodeWarning(false) // 立即关闭高节点数警告对话框 - // 使用 setTimeout 确保对话框关闭后再开始加载 - setTimeout(() => { - loadGraph(true) - }, 0) - }, [loadGraph]) - - // 节点点击事件 - const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { - setSelectedNodeData({ - id: node.id, - type: node.type as 'entity' | 'paragraph', - content: node.data.content, - }) - }, []) - - // 当节点数量或类型改变时自动刷新 - useEffect(() => { - // 跳过初始确认对话框时的加载 - if (showInitialConfirm) return - // 只有用户确认后才能自动刷新 - if (!userConfirmedLoad) return - - // 参数变化时加载,会根据节点数自动判断是否需要警告 - loadGraph() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeLimit, nodeType, showInitialConfirm, userConfirmedLoad]) // 不依赖 loadGraph - - // 边点击事件 - const onEdgeClick = useCallback((_: React.MouseEvent, edge: Edge) => { - const sourceNode = nodes.find(n => n.id === edge.source) - const targetNode = nodes.find(n => n.id === edge.target) - const edgeData = edges.find(e => e.id === edge.id) - - if (sourceNode && targetNode && edgeData) { - setSelectedEdgeData({ - source: { - id: sourceNode.id, - type: sourceNode.type as 'entity' | 'paragraph', - content: sourceNode.data.content, - }, - target: { - id: targetNode.id, - type: targetNode.type as 'entity' | 'paragraph', - content: targetNode.data.content, - }, - edge: { - source: edge.source, - target: edge.target, - weight: parseFloat(edge.label as string || '0'), - }, - }) - } - }, [nodes, edges]) - - return ( -
- {/* 顶部工具栏 */} -
-
-
-

麦麦知识库图谱

-

可视化知识实体与关系网络

-
- - {stats && ( -
- - - 节点: {stats.total_nodes} - - - - 边: {stats.total_edges} - - - - 实体: {stats.entity_nodes} - - - - 段落: {stats.paragraph_nodes} - -
- )} -
- - {/* 搜索和控制栏 */} -
-
- setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - className="flex-1" - /> - - -
- -
- - - - - {showCustomInput && ( - setCustomLimit(e.target.value)} - onBlur={() => { - const num = parseInt(customLimit) - if (!isNaN(num) && num >= 50) { - setNodeLimit(num) - } else { - setCustomLimit('50') - setNodeLimit(50) - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - const num = parseInt(customLimit) - if (!isNaN(num) && num >= 50) { - setNodeLimit(num) - } else { - setCustomLimit('50') - setNodeLimit(50) - } - } - }} - placeholder="最少50个" - className="w-[120px]" - /> - )} - - -
-
-
- - {/* 主内容区域 */} -
- {loading ? ( -
-
- -

加载知识图谱中...

-
-
- ) : nodes.length === 0 ? ( -
-
- -

知识库为空

-

请先导入知识数据

-
-
- ) : ( - - - - {/* 节点数超过500时禁用MiniMap提升性能 */} - {nodeCount <= 500 && ( - - )} - - {/* 图例 */} - -
图例
-
-
-
- 实体节点 -
-
-
- 段落节点 -
- {nodeCount > 200 && ( -
-
性能模式
-
已禁用动画
- {nodeCount > 500 &&
已禁用缩略图
} -
- )} -
- - - )} -
- - {/* 节点详情对话框 */} - !open && setSelectedNodeData(null)}> - - - 节点详情 - - {selectedNodeData && ( - -
-
-
- -
- - {selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'} - -
-
-
- -
- - - {selectedNodeData.id} - -
- -
- -
-

{selectedNodeData.content}

-
- {selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && ( -
-

- 💡 提示:段落内容显示不完整? -
- 您可以在 配置 → WebUI 服务配置 中启用 "在知识图谱中加载段落完整内容" 选项,以显示段落的完整文本。 -
- 注意:此功能会额外再次加载 embedding store,占用约数百MB内存。不建议在生产环境中长期开启。 -

-
- )} -
-
-
- )} -
-
- - {/* 边详情对话框 */} - !open && setSelectedEdgeData(null)}> - - - 边详情 - - {selectedEdgeData && ( - -
-
-
-
源节点
-
{selectedEdgeData.source.content}
- - {selectedEdgeData.source.id.slice(0, 40)}... - -
- -
- -
-
目标节点
-
{selectedEdgeData.target.content}
- - {selectedEdgeData.target.id.slice(0, 40)}... - -
-
- -
- -
- - {selectedEdgeData.edge.weight.toFixed(4)} - -
-
-
-
- )} -
-
- - {/* 初始加载确认对话框 */} - - - - 加载知识图谱 - - 知识图谱的动态展示会消耗较多系统资源。 -
- 确定要加载知识图谱吗? -
-
- - navigate({ to: '/' })}> - 取消 (返回首页) - - - 确认加载 - - -
-
- - {/* 高节点数警告对话框 */} - - - - ⚠️ 节点数量较多 - -
-

- 您正在尝试加载 {nodeLimit >= 10000 ? '全部 (最多10000个)' : nodeLimit} 个节点。 -

-

节点数量过多可能导致:

-
    -
  • 页面加载时间较长
  • -
  • 浏览器卡顿或崩溃
  • -
  • 系统资源占用过高
  • -
-

建议先选择较少的节点数量 (50-200 个)。

-
-
-
- - { - setShowHighNodeWarning(false) - // 将节点数重置为安全值 - if (nodeLimit > 200) { - setNodeLimit(50) - setShowCustomInput(false) - } - }}> - 取消 - - - 我了解风险,继续加载 - - -
-
-
- ) -} diff --git a/dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx b/dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx new file mode 100644 index 00000000..51ad8db7 --- /dev/null +++ b/dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx @@ -0,0 +1,122 @@ + +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { ScrollArea } from '@/components/ui/scroll-area' + +import type { GraphNode, SelectedEdgeData } from './types' + +interface NodeDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedNodeData: GraphNode | null +} + +export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeDetailDialogProps) { + return ( + + + + 节点详情 + + {selectedNodeData && ( + +
+
+
+ +
+ + {selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'} + +
+
+
+ +
+ + + {selectedNodeData.id} + +
+ +
+ +
+

{selectedNodeData.content}

+
+ {selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && ( +
+

+ 💡 提示:段落内容显示不完整? +
+ 您可以在 配置 → WebUI 服务配置 中启用 "在知识图谱中加载段落完整内容" 选项,以显示段落的完整文本。 +
+ 注意:此功能会额外再次加载 embedding store,占用约数百MB内存。不建议在生产环境中长期开启。 +

+
+ )} +
+
+
+ )} +
+
+ ) +} + +interface EdgeDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedEdgeData: SelectedEdgeData | null +} + +export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeDetailDialogProps) { + return ( + + + + 边详情 + + {selectedEdgeData && ( + +
+
+
+
源节点
+
{selectedEdgeData.source.content}
+ + {selectedEdgeData.source.id.slice(0, 40)}... + +
+ +
+ +
+
目标节点
+
{selectedEdgeData.target.content}
+ + {selectedEdgeData.target.id.slice(0, 40)}... + +
+
+ +
+ +
+ + {selectedEdgeData.edge.weight.toFixed(4)} + +
+
+
+
+ )} +
+
+ ) +} diff --git a/dashboard/src/routes/resource/knowledge-graph/GraphVisualization.tsx b/dashboard/src/routes/resource/knowledge-graph/GraphVisualization.tsx new file mode 100644 index 00000000..2ea59f06 --- /dev/null +++ b/dashboard/src/routes/resource/knowledge-graph/GraphVisualization.tsx @@ -0,0 +1,182 @@ +import { memo, useCallback } from 'react' +import ReactFlow, { + Background, + BackgroundVariant, + Controls, + Handle, + MiniMap, + Panel, + Position, + useEdgesState, + useNodesState, + type Edge, + type Node, + type NodeTypes, +} from 'reactflow' + +import 'reactflow/dist/style.css' +import dagre from 'dagre' + +import type { FlowEdge, FlowNode, GraphEdge, GraphNode } from './types' + +const EntityNode = memo(({ data }: { data: { label: string; content: string } }) => { + return ( +
+ +
+ {data.label} +
+ +
+ ) +}) + +EntityNode.displayName = 'EntityNode' + +const ParagraphNode = memo(({ data }: { data: { label: string; content: string } }) => { + return ( +
+ +
+ {data.label} +
+ +
+ ) +}) + +ParagraphNode.displayName = 'ParagraphNode' + +const nodeTypes: NodeTypes = { + entity: EntityNode, + paragraph: ParagraphNode, +} + +function calculateLayout(nodes: GraphNode[], edges: GraphEdge[]): { nodes: FlowNode[]; edges: FlowEdge[] } { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + dagreGraph.setGraph({ rankdir: 'TB', ranksep: 100, nodesep: 80 }) + + const flowNodes: FlowNode[] = [] + const flowEdges: FlowEdge[] = [] + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { width: 150, height: 50 }) + }) + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }) + + dagre.layout(dagreGraph) + + nodes.forEach((node) => { + const nodeWithPosition = dagreGraph.node(node.id) + flowNodes.push({ + id: node.id, + type: node.type, + position: { + x: nodeWithPosition.x - 75, + y: nodeWithPosition.y - 25, + }, + data: { + label: node.content.slice(0, 20) + (node.content.length > 20 ? '...' : ''), + content: node.content, + }, + }) + }) + + edges.forEach((edge, index) => { + const flowEdge: FlowEdge = { + id: `edge-${index}`, + source: edge.source, + target: edge.target, + animated: nodes.length <= 200 && edge.weight > 5, + style: { + strokeWidth: Math.min(edge.weight / 2, 5), + opacity: 0.6, + }, + } + if (edge.weight > 10 && nodes.length < 100) { + flowEdge.label = `${edge.weight.toFixed(0)}` + } + flowEdges.push(flowEdge) + }) + + return { nodes: flowNodes, edges: flowEdges } +} + +interface GraphVisualizationProps { + graphData: { nodes: GraphNode[]; edges: GraphEdge[] } + onNodeClick: (event: React.MouseEvent, node: Node) => void + onEdgeClick: (event: React.MouseEvent, edge: Edge) => void + loading?: boolean +} + +export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loading = false }: GraphVisualizationProps) { + const { nodes: flowNodes, edges: flowEdges } = calculateLayout(graphData.nodes, graphData.edges) + const [nodes, , onNodesChange] = useNodesState(flowNodes) + const [edges, , onEdgesChange] = useEdgesState(flowEdges) + const nodeCount = nodes.length + + const miniMapNodeColor = useCallback((node: Node) => { + if (node.type === 'entity') return '#6366f1' + if (node.type === 'paragraph') return '#10b981' + return '#6b7280' + }, []) + + if (loading) { + return null + } + + return ( + + + + {nodeCount <= 500 && ( + + )} + + +
图例
+
+
+
+ 实体节点 +
+
+
+ 段落节点 +
+ {nodeCount > 200 && ( +
+
性能模式
+
已禁用动画
+ {nodeCount > 500 &&
已禁用缩略图
} +
+ )} +
+ + + ) +} diff --git a/dashboard/src/routes/resource/knowledge-graph/index.ts b/dashboard/src/routes/resource/knowledge-graph/index.ts new file mode 100644 index 00000000..a4487ff5 --- /dev/null +++ b/dashboard/src/routes/resource/knowledge-graph/index.ts @@ -0,0 +1 @@ +export { KnowledgeGraphPage } from './index.tsx' diff --git a/dashboard/src/routes/resource/knowledge-graph/index.tsx b/dashboard/src/routes/resource/knowledge-graph/index.tsx new file mode 100644 index 00000000..497fff70 --- /dev/null +++ b/dashboard/src/routes/resource/knowledge-graph/index.tsx @@ -0,0 +1,427 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import type { Edge, Node } from 'reactflow' + +import { Database, FileText, Info, Network, RefreshCw, Search } from 'lucide-react' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useToast } from '@/hooks/use-toast' +import { + getKnowledgeGraph, + getKnowledgeStats, + searchKnowledgeNode, + type KnowledgeStats, +} from '@/lib/knowledge-api' +import { cn } from '@/lib/utils' + +import { EdgeDetailDialog, NodeDetailDialog } from './GraphDialogs' +import { GraphVisualization } from './GraphVisualization' +import type { GraphData, GraphNode, SelectedEdgeData } from './types' + +export function KnowledgeGraphPage() { + const navigate = useNavigate() + const [loading, setLoading] = useState(false) + const [stats, setStats] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [nodeType, setNodeType] = useState<'all' | 'entity' | 'paragraph'>('all') + const [nodeLimit, setNodeLimit] = useState(50) + const [customLimit, setCustomLimit] = useState('50') + const [showCustomInput, setShowCustomInput] = useState(false) + const [showInitialConfirm, setShowInitialConfirm] = useState(true) + const [userConfirmedLoad, setUserConfirmedLoad] = useState(false) + const [showHighNodeWarning, setShowHighNodeWarning] = useState(false) + const [graphData, setGraphData] = useState({ nodes: [], edges: [] }) + const [selectedNodeData, setSelectedNodeData] = useState(null) + const [selectedEdgeData, setSelectedEdgeData] = useState(null) + const { toast } = useToast() + + const loadGraph = useCallback(async (skipWarning = false) => { + try { + if (!skipWarning && nodeLimit > 200) { + setShowHighNodeWarning(true) + return + } + + setLoading(true) + const [graphResult, statsData] = await Promise.all([ + getKnowledgeGraph(nodeLimit, nodeType), + getKnowledgeStats(), + ]) + + setStats(statsData) + + if (graphResult.nodes.length === 0) { + toast({ + title: '提示', + description: '知识库为空,请先导入知识数据', + }) + setGraphData({ nodes: [], edges: [] }) + return + } + + setGraphData({ nodes: graphResult.nodes, edges: graphResult.edges }) + + if (statsData && statsData.total_nodes > nodeLimit) { + toast({ + title: '提示', + description: `知识图谱包含 ${statsData.total_nodes} 个节点,当前显示 ${graphResult.nodes.length} 个`, + }) + } + + toast({ + title: '加载成功', + description: `已加载 ${graphResult.nodes.length} 个节点,${graphResult.edges.length} 条边`, + }) + } catch (error) { + console.error('加载知识图谱失败:', error) + toast({ + title: '加载失败', + description: error instanceof Error ? error.message : '未知错误', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + }, [nodeLimit, nodeType, toast]) + + const handleSearch = useCallback(async () => { + if (!searchQuery.trim()) { + toast({ + title: '提示', + description: '请输入搜索关键词', + }) + return + } + + try { + const results = await searchKnowledgeNode(searchQuery) + if (results.length === 0) { + toast({ + title: '未找到', + description: '没有找到匹配的节点', + }) + return + } + + toast({ + title: '搜索完成', + description: `找到 ${results.length} 个匹配节点`, + }) + } catch (error) { + console.error('搜索失败:', error) + toast({ + title: '搜索失败', + description: error instanceof Error ? error.message : '未知错误', + variant: 'destructive', + }) + } + }, [searchQuery, toast]) + + const handleResetHighlight = useCallback(() => { + toast({ + title: '提示', + description: '已重置高亮', + }) + }, [toast]) + + const handleInitialConfirm = useCallback(() => { + setShowInitialConfirm(false) + setUserConfirmedLoad(true) + loadGraph() + }, [loadGraph]) + + const handleHighNodeConfirm = useCallback(() => { + setShowHighNodeWarning(false) + setTimeout(() => { + loadGraph(true) + }, 0) + }, [loadGraph]) + + const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { + setSelectedNodeData({ + id: node.id, + type: node.type as 'entity' | 'paragraph', + content: node.data.content, + }) + }, []) + + useEffect(() => { + if (showInitialConfirm) return + if (!userConfirmedLoad) return + + loadGraph() + }, [nodeLimit, nodeType, showInitialConfirm, userConfirmedLoad]) + + const onEdgeClick = useCallback((_: React.MouseEvent, edge: Edge) => { + const sourceNode = graphData.nodes.find(n => n.id === edge.source) + const targetNode = graphData.nodes.find(n => n.id === edge.target) + const edgeData = graphData.edges.find(e => e.source === edge.source && e.target === edge.target) + + if (sourceNode && targetNode && edgeData) { + setSelectedEdgeData({ + source: { + id: sourceNode.id, + type: sourceNode.type as 'entity' | 'paragraph', + content: sourceNode.content, + }, + target: { + id: targetNode.id, + type: targetNode.type as 'entity' | 'paragraph', + content: targetNode.content, + }, + edge: { + source: edge.source, + target: edge.target, + weight: parseFloat(edge.label as string || '0'), + }, + }) + } + }, [graphData]) + + return ( +
+
+
+
+

麦麦知识库图谱

+

可视化知识实体与关系网络

+
+ + {stats && ( +
+ + + 节点: {stats.total_nodes} + + + + 边: {stats.total_edges} + + + + 实体: {stats.entity_nodes} + + + + 段落: {stats.paragraph_nodes} + +
+ )} +
+ +
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="flex-1" + /> + + +
+ +
+ + + + + {showCustomInput && ( + setCustomLimit(e.target.value)} + onBlur={() => { + const num = parseInt(customLimit) + if (!isNaN(num) && num >= 50) { + setNodeLimit(num) + } else { + setCustomLimit('50') + setNodeLimit(50) + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const num = parseInt(customLimit) + if (!isNaN(num) && num >= 50) { + setNodeLimit(num) + } else { + setCustomLimit('50') + setNodeLimit(50) + } + } + }} + placeholder="最少50个" + className="w-[120px]" + /> + )} + + +
+
+
+ +
+ {loading ? ( +
+
+ +

加载知识图谱中...

+
+
+ ) : graphData.nodes.length === 0 ? ( +
+
+ +

知识库为空

+

请先导入知识数据

+
+
+ ) : ( + + )} +
+ + !open && setSelectedNodeData(null)} + selectedNodeData={selectedNodeData} + /> + + !open && setSelectedEdgeData(null)} + selectedEdgeData={selectedEdgeData} + /> + + + + + 加载知识图谱 + + 知识图谱的动态展示会消耗较多系统资源。 +
+ 确定要加载知识图谱吗? +
+
+ + navigate({ to: '/' })}> + 取消 (返回首页) + + + 确认加载 + + +
+
+ + + + + ⚠️ 节点数量较多 + +
+

+ 您正在尝试加载 {nodeLimit >= 10000 ? '全部 (最多10000个)' : nodeLimit} 个节点。 +

+

节点数量过多可能导致:

+
    +
  • 页面加载时间较长
  • +
  • 浏览器卡顿或崩溃
  • +
  • 系统资源占用过高
  • +
+

建议先选择较少的节点数量 (50-200 个)。

+
+
+
+ + { + setShowHighNodeWarning(false) + if (nodeLimit > 200) { + setNodeLimit(50) + setShowCustomInput(false) + } + }}> + 取消 + + + 我了解风险,继续加载 + + +
+
+
+ ) +} diff --git a/dashboard/src/routes/resource/knowledge-graph/types.ts b/dashboard/src/routes/resource/knowledge-graph/types.ts new file mode 100644 index 00000000..76fc7091 --- /dev/null +++ b/dashboard/src/routes/resource/knowledge-graph/types.ts @@ -0,0 +1,39 @@ +import type { Node, Edge } from 'reactflow' + +export interface GraphNode { + id: string + type: 'entity' | 'paragraph' + content: string +} + +export interface GraphEdge { + source: string + target: string + weight: number +} + +export interface GraphData { + nodes: GraphNode[] + edges: GraphEdge[] +} + +export interface GraphStats { + total_nodes: number + total_edges: number + entity_nodes: number + paragraph_nodes: number +} + +export interface FlowNodeData { + label: string + content: string +} + +export type FlowNode = Node +export type FlowEdge = Edge + +export interface SelectedEdgeData { + source: GraphNode + target: GraphNode + edge: GraphEdge +}