refactor(routes): split knowledge-graph.tsx into modular knowledge-graph/ directory (T19d)
This commit is contained in:
@@ -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 (
|
||||
<div className="px-4 py-2 shadow-md rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700 min-w-[120px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-semibold text-white text-sm truncate max-w-[200px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
EntityNode.displayName = 'EntityNode'
|
||||
|
||||
// 自定义节点组件 - 段落节点
|
||||
const ParagraphNode = memo(({ data }: { data: { label: string; content: string } }) => {
|
||||
return (
|
||||
<div className="px-3 py-2 shadow-md rounded-md bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700 min-w-[100px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-medium text-white text-xs truncate max-w-[150px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
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<KnowledgeStats | null>(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<KnowledgeNode | null>(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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex-shrink-0 p-4 border-b bg-background">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">麦麦知识库图谱</h1>
|
||||
<p className="text-muted-foreground mt-1">可视化知识实体与关系网络</p>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Database className="h-3 w-3" />
|
||||
节点: {stats.total_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Network className="h-3 w-3" />
|
||||
边: {stats.total_edges}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
实体: {stats.entity_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
段落: {stats.paragraph_nodes}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索和控制栏 */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Input
|
||||
placeholder="搜索节点内容..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearch} size="sm">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={handleResetHighlight} variant="outline" size="sm">
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={nodeType} onValueChange={(v) => setNodeType(v as 'all' | 'entity' | 'paragraph')}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部节点</SelectItem>
|
||||
<SelectItem value="entity">仅实体</SelectItem>
|
||||
<SelectItem value="paragraph">仅段落</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={
|
||||
nodeLimit === 10000 ? 'all' :
|
||||
showCustomInput ? 'custom' :
|
||||
nodeLimit.toString()
|
||||
}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'custom') {
|
||||
setShowCustomInput(true)
|
||||
setCustomLimit(nodeLimit.toString())
|
||||
} else if (v === 'all') {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(10000)
|
||||
} else {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(Number(v))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50 节点</SelectItem>
|
||||
<SelectItem value="100">100 节点</SelectItem>
|
||||
<SelectItem value="200">200 节点</SelectItem>
|
||||
<SelectItem value="500">500 节点</SelectItem>
|
||||
<SelectItem value="1000">1000 节点</SelectItem>
|
||||
<SelectItem value="all">全部 (最多10000)</SelectItem>
|
||||
<SelectItem value="custom">自定义...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showCustomInput && (
|
||||
<Input
|
||||
type="number"
|
||||
min="50"
|
||||
value={customLimit}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={() => loadGraph()} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">加载知识图谱中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">知识库为空</h3>
|
||||
<p className="text-muted-foreground">请先导入知识数据</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.05}
|
||||
maxZoom={1.5}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||
elevateNodesOnSelect={nodeCount <= 500}
|
||||
nodesDraggable={nodeCount <= 1000}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls />
|
||||
{/* 节点数超过500时禁用MiniMap提升性能 */}
|
||||
{nodeCount <= 500 && (
|
||||
<MiniMap
|
||||
nodeColor={miniMapNodeColor}
|
||||
nodeBorderRadius={8}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图例 */}
|
||||
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
|
||||
<div className="text-sm font-semibold mb-2">图例</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" />
|
||||
<span>实体节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
{nodeCount > 200 && (
|
||||
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
|
||||
<div className="font-semibold">性能模式</div>
|
||||
<div>已禁用动画</div>
|
||||
{nodeCount > 500 && <div>已禁用缩略图</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 节点详情对话框 */}
|
||||
<Dialog open={!!selectedNodeData} onOpenChange={(open) => !open && setSelectedNodeData(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>节点详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedNodeData && (
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">类型</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
|
||||
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">ID</label>
|
||||
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
|
||||
{selectedNodeData.id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">内容</label>
|
||||
<div className="mt-1 p-3 bg-muted rounded border">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
|
||||
</div>
|
||||
{selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && (
|
||||
<div className="mt-2 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
💡 <strong>提示:</strong>段落内容显示不完整?
|
||||
<br />
|
||||
您可以在 <strong>配置 → WebUI 服务配置</strong> 中启用 "在知识图谱中加载段落完整内容" 选项,以显示段落的完整文本。
|
||||
<br />
|
||||
注意:此功能会额外再次加载 embedding store,占用约数百MB内存。不建议在生产环境中长期开启。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 边详情对话框 */}
|
||||
<Dialog open={!!selectedEdgeData} onOpenChange={(open) => !open && setSelectedEdgeData(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>边详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEdgeData && (
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0 p-3 bg-blue-50 dark:bg-blue-950 rounded border-2 border-blue-200 dark:border-blue-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">源节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.source.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.source.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl text-muted-foreground flex-shrink-0">→</div>
|
||||
|
||||
<div className="flex-1 min-w-0 p-3 bg-green-50 dark:bg-green-950 rounded border-2 border-green-200 dark:border-green-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">目标节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.target.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.target.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">权重</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-base font-mono">
|
||||
{selectedEdgeData.edge.weight.toFixed(4)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 初始加载确认对话框 */}
|
||||
<AlertDialog open={showInitialConfirm} onOpenChange={setShowInitialConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>加载知识图谱</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
知识图谱的动态展示会消耗较多系统资源。
|
||||
<br />
|
||||
确定要加载知识图谱吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => navigate({ to: '/' })}>
|
||||
取消 (返回首页)
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleInitialConfirm}>
|
||||
确认加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 高节点数警告对话框 */}
|
||||
<AlertDialog open={showHighNodeWarning} onOpenChange={setShowHighNodeWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>⚠️ 节点数量较多</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<p>
|
||||
您正在尝试加载 <strong className="text-orange-600">{nodeLimit >= 10000 ? '全部 (最多10000个)' : nodeLimit}</strong> 个节点。
|
||||
</p>
|
||||
<p className="mt-4">节点数量过多可能导致:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>页面加载时间较长</li>
|
||||
<li>浏览器卡顿或崩溃</li>
|
||||
<li>系统资源占用过高</li>
|
||||
</ul>
|
||||
<p className="mt-4">建议先选择较少的节点数量 (50-200 个)。</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => {
|
||||
setShowHighNodeWarning(false)
|
||||
// 将节点数重置为安全值
|
||||
if (nodeLimit > 200) {
|
||||
setNodeLimit(50)
|
||||
setShowCustomInput(false)
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleHighNodeConfirm} className="bg-orange-600 hover:bg-orange-700">
|
||||
我了解风险,继续加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx
Normal file
122
dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>节点详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedNodeData && (
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">类型</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
|
||||
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">ID</label>
|
||||
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
|
||||
{selectedNodeData.id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">内容</label>
|
||||
<div className="mt-1 p-3 bg-muted rounded border">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
|
||||
</div>
|
||||
{selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && (
|
||||
<div className="mt-2 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
💡 <strong>提示:</strong>段落内容显示不完整?
|
||||
<br />
|
||||
您可以在 <strong>配置 → WebUI 服务配置</strong> 中启用 "在知识图谱中加载段落完整内容" 选项,以显示段落的完整文本。
|
||||
<br />
|
||||
注意:此功能会额外再次加载 embedding store,占用约数百MB内存。不建议在生产环境中长期开启。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface EdgeDetailDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedEdgeData: SelectedEdgeData | null
|
||||
}
|
||||
|
||||
export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeDetailDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>边详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEdgeData && (
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0 p-3 bg-blue-50 dark:bg-blue-950 rounded border-2 border-blue-200 dark:border-blue-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">源节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.source.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.source.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl text-muted-foreground flex-shrink-0">→</div>
|
||||
|
||||
<div className="flex-1 min-w-0 p-3 bg-green-50 dark:bg-green-950 rounded border-2 border-green-200 dark:border-green-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">目标节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.target.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.target.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">权重</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-base font-mono">
|
||||
{selectedEdgeData.edge.weight.toFixed(4)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="px-4 py-2 shadow-md rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700 min-w-[120px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-semibold text-white text-sm truncate max-w-[200px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
EntityNode.displayName = 'EntityNode'
|
||||
|
||||
const ParagraphNode = memo(({ data }: { data: { label: string; content: string } }) => {
|
||||
return (
|
||||
<div className="px-3 py-2 shadow-md rounded-md bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700 min-w-[100px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-medium text-white text-xs truncate max-w-[150px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.05}
|
||||
maxZoom={1.5}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||
elevateNodesOnSelect={nodeCount <= 500}
|
||||
nodesDraggable={nodeCount <= 1000}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls />
|
||||
{nodeCount <= 500 && (
|
||||
<MiniMap
|
||||
nodeColor={miniMapNodeColor}
|
||||
nodeBorderRadius={8}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
)}
|
||||
|
||||
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
|
||||
<div className="text-sm font-semibold mb-2">图例</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" />
|
||||
<span>实体节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
{nodeCount > 200 && (
|
||||
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
|
||||
<div className="font-semibold">性能模式</div>
|
||||
<div>已禁用动画</div>
|
||||
{nodeCount > 500 && <div>已禁用缩略图</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
1
dashboard/src/routes/resource/knowledge-graph/index.ts
Normal file
1
dashboard/src/routes/resource/knowledge-graph/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { KnowledgeGraphPage } from './index.tsx'
|
||||
427
dashboard/src/routes/resource/knowledge-graph/index.tsx
Normal file
427
dashboard/src/routes/resource/knowledge-graph/index.tsx
Normal file
@@ -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<KnowledgeStats | null>(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<GraphData>({ nodes: [], edges: [] })
|
||||
const [selectedNodeData, setSelectedNodeData] = useState<GraphNode | null>(null)
|
||||
const [selectedEdgeData, setSelectedEdgeData] = useState<SelectedEdgeData | null>(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 (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-shrink-0 p-4 border-b bg-background">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">麦麦知识库图谱</h1>
|
||||
<p className="text-muted-foreground mt-1">可视化知识实体与关系网络</p>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Database className="h-3 w-3" />
|
||||
节点: {stats.total_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Network className="h-3 w-3" />
|
||||
边: {stats.total_edges}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
实体: {stats.entity_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
段落: {stats.paragraph_nodes}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Input
|
||||
placeholder="搜索节点内容..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearch} size="sm">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={handleResetHighlight} variant="outline" size="sm">
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={nodeType} onValueChange={(v) => setNodeType(v as 'all' | 'entity' | 'paragraph')}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部节点</SelectItem>
|
||||
<SelectItem value="entity">仅实体</SelectItem>
|
||||
<SelectItem value="paragraph">仅段落</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={
|
||||
nodeLimit === 10000 ? 'all' :
|
||||
showCustomInput ? 'custom' :
|
||||
nodeLimit.toString()
|
||||
}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'custom') {
|
||||
setShowCustomInput(true)
|
||||
setCustomLimit(nodeLimit.toString())
|
||||
} else if (v === 'all') {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(10000)
|
||||
} else {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(Number(v))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50 节点</SelectItem>
|
||||
<SelectItem value="100">100 节点</SelectItem>
|
||||
<SelectItem value="200">200 节点</SelectItem>
|
||||
<SelectItem value="500">500 节点</SelectItem>
|
||||
<SelectItem value="1000">1000 节点</SelectItem>
|
||||
<SelectItem value="all">全部 (最多10000)</SelectItem>
|
||||
<SelectItem value="custom">自定义...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showCustomInput && (
|
||||
<Input
|
||||
type="number"
|
||||
min="50"
|
||||
value={customLimit}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={() => loadGraph()} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">加载知识图谱中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : graphData.nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">知识库为空</h3>
|
||||
<p className="text-muted-foreground">请先导入知识数据</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<GraphVisualization
|
||||
graphData={graphData}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NodeDetailDialog
|
||||
open={!!selectedNodeData}
|
||||
onOpenChange={(open) => !open && setSelectedNodeData(null)}
|
||||
selectedNodeData={selectedNodeData}
|
||||
/>
|
||||
|
||||
<EdgeDetailDialog
|
||||
open={!!selectedEdgeData}
|
||||
onOpenChange={(open) => !open && setSelectedEdgeData(null)}
|
||||
selectedEdgeData={selectedEdgeData}
|
||||
/>
|
||||
|
||||
<AlertDialog open={showInitialConfirm} onOpenChange={setShowInitialConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>加载知识图谱</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
知识图谱的动态展示会消耗较多系统资源。
|
||||
<br />
|
||||
确定要加载知识图谱吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => navigate({ to: '/' })}>
|
||||
取消 (返回首页)
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleInitialConfirm}>
|
||||
确认加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showHighNodeWarning} onOpenChange={setShowHighNodeWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>⚠️ 节点数量较多</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<p>
|
||||
您正在尝试加载 <strong className="text-orange-600">{nodeLimit >= 10000 ? '全部 (最多10000个)' : nodeLimit}</strong> 个节点。
|
||||
</p>
|
||||
<p className="mt-4">节点数量过多可能导致:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>页面加载时间较长</li>
|
||||
<li>浏览器卡顿或崩溃</li>
|
||||
<li>系统资源占用过高</li>
|
||||
</ul>
|
||||
<p className="mt-4">建议先选择较少的节点数量 (50-200 个)。</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => {
|
||||
setShowHighNodeWarning(false)
|
||||
if (nodeLimit > 200) {
|
||||
setNodeLimit(50)
|
||||
setShowCustomInput(false)
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleHighNodeConfirm} className="bg-orange-600 hover:bg-orange-700">
|
||||
我了解风险,继续加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
dashboard/src/routes/resource/knowledge-graph/types.ts
Normal file
39
dashboard/src/routes/resource/knowledge-graph/types.ts
Normal file
@@ -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<FlowNodeData>
|
||||
export type FlowEdge = Edge
|
||||
|
||||
export interface SelectedEdgeData {
|
||||
source: GraphNode
|
||||
target: GraphNode
|
||||
edge: GraphEdge
|
||||
}
|
||||
Reference in New Issue
Block a user