import { Loader2, Network, RotateCcw, Tags, Undo2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { GraphCanvas, type GraphCanvasHandle, } from "@/components/knowledge-graph/GraphCanvas"; import { GraphDetailPanel } from "@/components/knowledge-graph/GraphDetailPanel"; import { GraphFooter } from "@/components/knowledge-graph/GraphFooter"; import { GraphHeader } from "@/components/knowledge-graph/GraphHeader"; import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar"; import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip"; import type { ExploreStep, GraphMode, LayoutType, NodeKind, TooltipState, } from "@/components/knowledge-graph/graphTypes"; import { computeDegrees, computeGraphStats, getEdgeId, getEdgeLabel, getNodeKind, getNodeTitle, } from "@/components/knowledge-graph/graphUtils"; import "@/components/knowledge-graph/graphStyles.css"; import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext"; import { useGraphBlacklist, useGraphBlacklistActions, useKnowledgeGraph, useKnowledgeNote, } from "@/core/knowledge/hooks"; /** 全量图谱默认只渲染度数最高的核心节点,超出可手动切换显示全部。 */ const FULL_GRAPH_NODE_LIMIT = 300; /** 常见的低价值实体名(抽取噪声),用于"一键清理"候选。 */ const GENERIC_ENTITY_LABELS = new Set([ "用户", "助手", "系统", "问题", "回答", "内容", "信息", "数据", "功能", "方法", "结果", "示例", "例子", "总结", "文档", "文件", "其他", "未知", "对话", "聊天", "测试", "工具", "平台", "项目", "任务", "报告", "分析", "none", "null", "unknown", "test", "todo", ]); /** 单字符、纯数字/符号(日期等)或通用泛词视为低价值主题。 */ function isLowValueLabel(raw: string): boolean { const label = raw.trim(); if (label.length <= 1) return true; if (/^[\d\s\p{P}\p{S}]+$/u.test(label)) return true; return GENERIC_ENTITY_LABELS.has(label.toLowerCase()); } export default function KnowledgeGraphPage() { const navigate = useNavigate(); const { theme } = useTheme(); const pageRef = useRef(null); const canvasRef = useRef(null); const { data, isLoading, isError, error, refetch, isFetching } = useKnowledgeGraph(); const { data: blacklistItems = [] } = useGraphBlacklist(); const blacklistActions = useGraphBlacklistActions(); // ------------------------------------------------------------------ state const [selectedNodeId, setSelectedNodeId] = useState(null); const [searchKeyword, setSearchKeyword] = useState(""); const [disabledNodeKinds, setDisabledNodeKinds] = useState>( new Set(), ); const [disabledEdgeLabels, setDisabledEdgeLabels] = useState>( new Set(), ); const [hiddenNodeIds, setHiddenNodeIds] = useState>(new Set()); const [showAllNodes, setShowAllNodes] = useState(false); const [layoutType, setLayoutType] = useState("force"); const [showLabels, setShowLabels] = useState(true); const [showSidebar, setShowSidebar] = useState(true); const [showDetail, setShowDetail] = useState(false); const [zoomLevel, setZoomLevel] = useState(1); const [tooltip, setTooltip] = useState(null); const [graphMode, setGraphMode] = useState("full"); const [exploreRootId, setExploreRootId] = useState(null); const [expandedNodeIds, setExpandedNodeIds] = useState>(new Set()); const [visibleNodeIds, setVisibleNodeIds] = useState>(new Set()); const [visibleEdgeIds, setVisibleEdgeIds] = useState>(new Set()); const [nodeDepthMap, setNodeDepthMap] = useState>(new Map()); const [exploreHistory, setExploreHistory] = useState([]); // Auto-collapse the side panels on narrow viewports. useEffect(() => { const detailQuery = window.matchMedia("(max-width: 1280px)"); const sidebarQuery = window.matchMedia("(max-width: 900px)"); const apply = () => { if (detailQuery.matches) setShowDetail(false); setShowSidebar(!sidebarQuery.matches); }; apply(); detailQuery.addEventListener("change", apply); sidebarQuery.addEventListener("change", apply); return () => { detailQuery.removeEventListener("change", apply); sidebarQuery.removeEventListener("change", apply); }; }, []); // ---------------------------------------------------------------- derived const allNodes = useMemo(() => data?.nodes ?? [], [data]); const allEdges = useMemo(() => data?.edges ?? [], [data]); const nodeById = useMemo( () => new Map(allNodes.map((n) => [n.id, n])), [allNodes], ); const degrees = useMemo( () => computeDegrees(allNodes, allEdges), [allNodes, allEdges], ); const stats = useMemo( () => computeGraphStats(allNodes, allEdges), [allNodes, allEdges], ); const nodeKindCounts = useMemo(() => { const counts = new Map(); for (const n of allNodes) { const kind = getNodeKind(n); counts.set(kind, (counts.get(kind) ?? 0) + 1); } return counts; }, [allNodes]); const edgeLabelCounts = useMemo(() => { const counts = new Map(); for (const e of allEdges) { const label = getEdgeLabel(e); counts.set(label, (counts.get(label) ?? 0) + 1); } return counts; }, [allEdges]); const filteredNodes = useMemo( () => allNodes.filter( (n) => !disabledNodeKinds.has(getNodeKind(n)) && !hiddenNodeIds.has(n.id), ), [allNodes, disabledNodeKinds, hiddenNodeIds], ); const filteredEdges = useMemo(() => { const visible = new Set(filteredNodes.map((n) => n.id)); return allEdges.filter( (e) => visible.has(e.source) && visible.has(e.target) && !disabledEdgeLabels.has(getEdgeLabel(e)), ); }, [allEdges, filteredNodes, disabledEdgeLabels]); // 节点过多时全量模式默认只渲染度数最高的核心节点,避免布局/渲染卡顿。 const fullGraphTruncated = !showAllNodes && filteredNodes.length > FULL_GRAPH_NODE_LIMIT; const limitedNodes = useMemo(() => { if (!fullGraphTruncated) return filteredNodes; return [...filteredNodes] .sort((a, b) => (degrees.get(b.id) ?? 0) - (degrees.get(a.id) ?? 0)) .slice(0, FULL_GRAPH_NODE_LIMIT); }, [filteredNodes, fullGraphTruncated, degrees]); const limitedEdges = useMemo(() => { if (!fullGraphTruncated) return filteredEdges; const visible = new Set(limitedNodes.map((n) => n.id)); return filteredEdges.filter( (e) => visible.has(e.source) && visible.has(e.target), ); }, [filteredEdges, fullGraphTruncated, limitedNodes]); const resetExplore = useCallback(() => { setExploreRootId(null); setExpandedNodeIds(new Set()); setVisibleNodeIds(new Set()); setVisibleEdgeIds(new Set()); setNodeDepthMap(new Map()); setExploreHistory([]); }, []); const rebuildExploreState = useCallback((history: ExploreStep[]) => { const nextVisibleNodeIds = new Set(); const nextVisibleEdgeIds = new Set(); const nextNodeDepthMap = new Map(); const nextExpandedNodeIds = new Set(); for (const step of history) { for (const nodeId of step.addedNodeIds) { nextVisibleNodeIds.add(nodeId); if (!nextNodeDepthMap.has(nodeId)) nextNodeDepthMap.set(nodeId, step.depth); } for (const edgeId of step.addedEdgeIds) nextVisibleEdgeIds.add(edgeId); if (step.depth > 0) nextExpandedNodeIds.add(step.nodeId); } setVisibleNodeIds(nextVisibleNodeIds); setVisibleEdgeIds(nextVisibleEdgeIds); setNodeDepthMap(nextNodeDepthMap); setExpandedNodeIds(nextExpandedNodeIds); }, []); const startExploreFromNode = useCallback((nodeId: string) => { setExploreRootId(nodeId); setVisibleNodeIds(new Set([nodeId])); setVisibleEdgeIds(new Set()); setExpandedNodeIds(new Set()); setNodeDepthMap(new Map([[nodeId, 0]])); setExploreHistory([ { nodeId, depth: 0, addedNodeIds: [nodeId], addedEdgeIds: [], timestamp: Date.now(), }, ]); window.setTimeout(() => canvasRef.current?.focusNode(nodeId), 0); }, []); const expandOneHop = useCallback( (nodeId: string) => { const currentDepth = nodeDepthMap.get(nodeId) ?? 0; const nextDepth = currentDepth + 1; const addedNodeIds: string[] = []; const addedEdgeIds: string[] = []; const nextVisibleNodeIds = new Set(visibleNodeIds); const nextVisibleEdgeIds = new Set(visibleEdgeIds); const nextNodeDepthMap = new Map(nodeDepthMap); for (const edge of filteredEdges) { if (edge.source !== nodeId && edge.target !== nodeId) continue; const edgeId = getEdgeId(edge); const neighborId = edge.source === nodeId ? edge.target : edge.source; if (!nodeById.has(neighborId)) continue; if (!nextVisibleEdgeIds.has(edgeId)) { nextVisibleEdgeIds.add(edgeId); addedEdgeIds.push(edgeId); } if (!nextVisibleNodeIds.has(neighborId)) { nextVisibleNodeIds.add(neighborId); nextNodeDepthMap.set(neighborId, nextDepth); addedNodeIds.push(neighborId); } } nextVisibleNodeIds.add(nodeId); nextNodeDepthMap.set(nodeId, currentDepth); setVisibleNodeIds(nextVisibleNodeIds); setVisibleEdgeIds(nextVisibleEdgeIds); setNodeDepthMap(nextNodeDepthMap); setExpandedNodeIds((prev) => new Set(prev).add(nodeId)); setExploreHistory((prev) => [ ...prev, { nodeId, depth: nextDepth, addedNodeIds, addedEdgeIds, timestamp: Date.now() }, ]); }, [filteredEdges, nodeById, nodeDepthMap, visibleEdgeIds, visibleNodeIds], ); const undoExploreStep = useCallback(() => { if (exploreHistory.length <= 1) { resetExplore(); return; } const nextHistory = exploreHistory.slice(0, -1); setExploreHistory(nextHistory); rebuildExploreState(nextHistory); }, [exploreHistory, rebuildExploreState, resetExplore]); useEffect(() => { if (graphMode === "explore") resetExplore(); }, [ disabledNodeKinds, disabledEdgeLabels, hiddenNodeIds, searchKeyword, resetExplore, ]); const displayedGraph = useMemo(() => { if (graphMode === "full") return { nodes: limitedNodes, edges: limitedEdges }; if (!exploreRootId) return { nodes: limitedNodes, edges: [] }; return { nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)), edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))), }; }, [ allEdges, allNodes, exploreRootId, limitedEdges, limitedNodes, graphMode, visibleEdgeIds, visibleNodeIds, ]); const searchMatchIds = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); if (!keyword) return null; const matches = new Set(); for (const n of filteredNodes) { if (getNodeTitle(n).toLowerCase().includes(keyword)) matches.add(n.id); } return matches; }, [searchKeyword, filteredNodes]); const topNodes = useMemo(() => { // 搜索时核心节点榜单只保留命中的节点,与画布高亮保持一致。 const pool = searchMatchIds ? filteredNodes.filter((node) => searchMatchIds.has(node.id)) : allNodes; return pool .map((node) => ({ node, degree: degrees.get(node.id) ?? 0 })) .sort((a, b) => b.degree - a.degree) .slice(0, 8); }, [allNodes, filteredNodes, searchMatchIds, degrees]); const selectedNode = selectedNodeId ? (nodeById.get(selectedNodeId) ?? null) : null; const selectedNeighbors = useMemo(() => { if (!selectedNodeId) return []; const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = []; const seen = new Set(); for (const e of displayedGraph.edges) { const otherId = e.source === selectedNodeId ? e.target : e.target === selectedNodeId ? e.source : null; if (!otherId || seen.has(otherId)) continue; const other = nodeById.get(otherId); if (!other) continue; seen.add(otherId); items.push({ node: other, relation: getEdgeLabel(e) }); } return items.sort( (a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0), ); }, [selectedNodeId, displayedGraph.edges, nodeById, degrees, allNodes]); // Note detail for note-type nodes (summary / tags / timestamps / status). const noteId = selectedNode && (selectedNode.type || "").toLowerCase() === "note" ? selectedNode.id : undefined; const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId); // --------------------------------------------------------------- handlers const handleSelectNode = useCallback( (id: string | null) => { if (!id) { setSelectedNodeId(null); setShowDetail(false); return; } setSelectedNodeId(id); setShowDetail(true); if (graphMode !== "explore") return; if (!exploreRootId) { startExploreFromNode(id); return; } if (!visibleNodeIds.has(id)) return; if (!expandedNodeIds.has(id)) expandOneHop(id); }, [ expandedNodeIds, expandOneHop, exploreRootId, graphMode, startExploreFromNode, visibleNodeIds, ], ); const handlePickNode = useCallback( (id: string) => { setSelectedNodeId(id); setShowDetail(true); if (graphMode === "explore" && !exploreRootId) startExploreFromNode(id); canvasRef.current?.focusNode(id); }, [exploreRootId, graphMode, startExploreFromNode], ); const handleSearchEnter = useCallback(() => { if (!searchMatchIds || searchMatchIds.size === 0) return; const first = searchMatchIds.values().next().value; if (first) handlePickNode(first); }, [searchMatchIds, handlePickNode]); const handleToggleNodeKind = useCallback((kind: NodeKind) => { setDisabledNodeKinds((prev) => { const next = new Set(prev); if (next.has(kind)) next.delete(kind); else next.add(kind); return next; }); }, []); const handleToggleEdgeLabel = useCallback((label: string) => { setDisabledEdgeLabels((prev) => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next; }); }, []); const handleHideNode = useCallback( (id: string) => { setHiddenNodeIds((prev) => new Set(prev).add(id)); setSelectedNodeId((current) => (current === id ? null : current)); if (selectedNodeId === id) setShowDetail(false); }, [selectedNodeId], ); const handleRestoreHidden = useCallback(() => { setHiddenNodeIds(new Set()); }, []); // ------------------------------------------------------------- blacklist const addToBlacklist = blacklistActions.add; const removeFromBlacklist = blacklistActions.remove; const handleBlacklistNode = useCallback( (id: string) => { const node = nodeById.get(id); if (!node) return; const label = getNodeTitle(node); addToBlacklist.mutate([label], { onSuccess: () => toast.success(`已将「${label}」加入黑名单`), onError: (e) => toast.error(e instanceof Error ? e.message : "加入黑名单失败"), }); setSelectedNodeId((current) => (current === id ? null : current)); if (selectedNodeId === id) setShowDetail(false); }, [nodeById, addToBlacklist, selectedNodeId], ); const handleRemoveBlacklist = useCallback( (entryId: string) => { removeFromBlacklist.mutate(entryId, { onError: (e) => toast.error(e instanceof Error ? e.message : "移除黑名单失败"), }); }, [removeFromBlacklist], ); // 低价值主题候选:实体节点中的单字符 / 纯数字符号 / 通用泛词。 const junkCandidates = useMemo(() => { const blocked = new Set( blacklistItems.map((item) => item.label.trim().toLowerCase()), ); const seen = new Set(); const out: string[] = []; for (const n of allNodes) { if ((n.type || "").toLowerCase() !== "entity") continue; const label = getNodeTitle(n).trim(); const key = label.toLowerCase(); if (!label || seen.has(key) || blocked.has(key)) continue; if (!isLowValueLabel(label)) continue; seen.add(key); out.push(label); } return out; }, [allNodes, blacklistItems]); const handleCleanJunk = useCallback(() => { if (junkCandidates.length === 0) { toast.info("没有发现明显的低价值主题"); return; } const preview = junkCandidates.slice(0, 10).join("、"); const confirmed = window.confirm( `将 ${junkCandidates.length} 个低价值主题加入黑名单?\n例如:${preview}${junkCandidates.length > 10 ? " 等" : ""}`, ); if (!confirmed) return; addToBlacklist.mutate(junkCandidates, { onSuccess: (res) => toast.success(`已清理 ${res.added} 个低价值主题`), onError: (e) => toast.error(e instanceof Error ? e.message : "清理失败"), }); }, [junkCandidates, addToBlacklist]); const handleRefresh = useCallback(() => { void refetch().then((result) => { if (result.isError) { toast.error( result.error instanceof Error ? result.error.message : "刷新图谱失败", ); } }); }, [refetch]); const handleFullscreen = useCallback(() => { const el = pageRef.current; if (!el) return; if (document.fullscreenElement) void document.exitFullscreen(); else void el.requestFullscreen(); }, []); const handleViewSource = useCallback( (id: string) => { navigate(`/page/workspace/knowledge/${encodeURIComponent(id)}`); }, [navigate], ); const handleSetExploreRoot = useCallback( (id: string) => { setGraphMode("explore"); startExploreFromNode(id); setSelectedNodeId(id); setShowDetail(true); }, [startExploreFromNode], ); const exploreDepth = useMemo(() => { let max = 0; for (const depth of nodeDepthMap.values()) max = Math.max(max, depth); return max; }, [nodeDepthMap]); const exploreRootTitle = exploreRootId ? getNodeTitle(nodeById.get(exploreRootId) ?? { id: exploreRootId }) : null; const selectedExploreInfo = graphMode === "explore" && selectedNodeId ? { depth: nodeDepthMap.get(selectedNodeId) ?? null, expanded: expandedNodeIds.has(selectedNodeId), isRoot: selectedNodeId === exploreRootId, } : null; // ---------------------------------------------------------------- overlay let overlay: React.ReactNode = null; if (isLoading) { overlay = (
正在加载知识图谱...
); } else if (isError) { overlay = (
图谱加载失败
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
); } else if (allNodes.length === 0) { overlay = (
暂无知识图谱数据
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
); } else if (filteredNodes.length === 0) { overlay = (
当前筛选没有可显示的节点
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
); } const toolbar = ( <>
模式
{graphMode === "explore" ? ( <>
起点:{exploreRootTitle ? exploreRootTitle : "未选择"}
深度 {exploreDepth} · 已显示 {displayedGraph.nodes.length} 节点 /{" "} {displayedGraph.edges.length} 关系
) : null} {graphMode === "full" && filteredNodes.length > FULL_GRAPH_NODE_LIMIT ? (
{fullGraphTruncated ? `节点较多,已显示核心 ${FULL_GRAPH_NODE_LIMIT} / ${filteredNodes.length}` : `已显示全部 ${filteredNodes.length} 个节点`}
) : null}
布局
); const hint = graphMode === "explore" ? (
{!exploreRootId ? "点击任意节点作为起点,开始逐层探索知识关系。" : "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
) : null; return (
navigate("/page/workspace/knowledge")} onRelayout={() => canvasRef.current?.relayout()} onFitView={() => canvasRef.current?.fitView()} onZoomIn={() => canvasRef.current?.zoomIn()} onZoomOut={() => canvasRef.current?.zoomOut()} onExport={() => canvasRef.current?.exportImage()} onFullscreen={handleFullscreen} onRefresh={handleRefresh} />
{showSidebar ? ( setShowSidebar(false)} /> ) : null}
{!showSidebar ? ( ) : null} {!showDetail ? ( ) : null}
{showDetail ? ( canvasRef.current?.focusNode(id)} onHide={handleHideNode} onBlacklist={handleBlacklistNode} onPickNeighbor={handlePickNode} onExpandNode={expandOneHop} onSetExploreRoot={handleSetExploreRoot} onClose={() => setShowDetail(false)} /> ) : null}
); }