refactor(routes): split knowledge-graph.tsx into modular knowledge-graph/ directory (T19d)

This commit is contained in:
DrSmoothl
2026-03-01 21:05:40 +08:00
parent c8e93d4d50
commit 763412e483
6 changed files with 771 additions and 722 deletions

View File

@@ -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 storeMB内存
</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>
)
}

View 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 storeMB内存
</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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { KnowledgeGraphPage } from './index.tsx'

View 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>
)
}

View 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
}