Files
deerflow-code/frontend-web/src/pages/KnowledgeGraphPage.tsx
T

864 lines
28 KiB
TypeScript

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<HTMLDivElement>(null);
const canvasRef = useRef<GraphCanvasHandle>(null);
const { data, isLoading, isError, error, refetch, isFetching } =
useKnowledgeGraph();
const { data: blacklistItems = [] } = useGraphBlacklist();
const blacklistActions = useGraphBlacklistActions();
// ------------------------------------------------------------------ state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [searchKeyword, setSearchKeyword] = useState("");
const [disabledNodeKinds, setDisabledNodeKinds] = useState<Set<NodeKind>>(
new Set(),
);
const [disabledEdgeLabels, setDisabledEdgeLabels] = useState<Set<string>>(
new Set(),
);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
const [showAllNodes, setShowAllNodes] = useState(false);
const [layoutType, setLayoutType] = useState<LayoutType>("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<TooltipState | null>(null);
const [graphMode, setGraphMode] = useState<GraphMode>("full");
const [exploreRootId, setExploreRootId] = useState<string | null>(null);
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
const [visibleNodeIds, setVisibleNodeIds] = useState<Set<string>>(new Set());
const [visibleEdgeIds, setVisibleEdgeIds] = useState<Set<string>>(new Set());
const [nodeDepthMap, setNodeDepthMap] = useState<Map<string, number>>(new Map());
const [exploreHistory, setExploreHistory] = useState<ExploreStep[]>([]);
// 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<NodeKind, number>();
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<string, number>();
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<string>();
const nextVisibleEdgeIds = new Set<string>();
const nextNodeDepthMap = new Map<string, number>();
const nextExpandedNodeIds = new Set<string>();
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<string>();
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<string>();
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<string>();
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 = (
<div className="kg-canvas-status">
<Loader2 size={26} className="animate-spin" />
<div className="kg-canvas-status-title">正在加载知识图谱...</div>
</div>
);
} else if (isError) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">图谱加载失败</div>
<div className="kg-canvas-status-desc">
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
</div>
<button type="button" className="kg-icon-button" onClick={handleRefresh}>
重试
</button>
</div>
);
} else if (allNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">暂无知识图谱数据</div>
<div className="kg-canvas-status-desc">
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
</div>
</div>
);
} else if (filteredNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">当前筛选没有可显示的节点</div>
<div className="kg-canvas-status-desc">
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
</div>
</div>
);
}
const toolbar = (
<>
<div className="kg-floating-pill">
<span className="kg-pill-label">模式</span>
<button
type="button"
className={`kg-pill-button${graphMode === "explore" ? " kg-pill-button-active" : ""}`}
onClick={() => setGraphMode("explore")}
>
探索模式
</button>
<button
type="button"
className={`kg-pill-button${graphMode === "full" ? " kg-pill-button-active" : ""}`}
onClick={() => setGraphMode("full")}
>
全量图谱
</button>
</div>
{graphMode === "explore" ? (
<>
<div className="kg-floating-pill">
起点:{exploreRootTitle ? exploreRootTitle : "未选择"}
</div>
<div className="kg-floating-pill">
深度 {exploreDepth} · 已显示 {displayedGraph.nodes.length} 节点 /{" "}
{displayedGraph.edges.length} 关系
</div>
<button
type="button"
className="kg-explore-button"
disabled={exploreHistory.length === 0}
onClick={undoExploreStep}
>
<Undo2 size={13} /> 返回上一层
</button>
<button
type="button"
className="kg-explore-button"
disabled={!exploreRootId}
onClick={resetExplore}
>
<RotateCcw size={13} /> 重置探索
</button>
</>
) : null}
{graphMode === "full" && filteredNodes.length > FULL_GRAPH_NODE_LIMIT ? (
<div className="kg-floating-pill">
{fullGraphTruncated
? `节点较多,已显示核心 ${FULL_GRAPH_NODE_LIMIT} / ${filteredNodes.length}`
: `已显示全部 ${filteredNodes.length} 个节点`}
<button
type="button"
className="kg-pill-button"
title={
fullGraphTruncated
? "渲染全部节点(节点很多时可能卡顿)"
: "只渲染度数最高的核心节点"
}
onClick={() => setShowAllNodes((v) => !v)}
>
{fullGraphTruncated ? "显示全部" : "仅核心节点"}
</button>
</div>
) : null}
<div className="kg-floating-pill">
<span className="kg-pill-label">布局</span>
<button
type="button"
className={`kg-pill-button${layoutType === "force" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("force")}
>
力导向
</button>
<button
type="button"
className={`kg-pill-button${layoutType === "circular" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("circular")}
>
环形
</button>
</div>
<div className="kg-floating-pill">
<button
type="button"
className={`kg-pill-button${showLabels ? " kg-pill-button-active" : ""}`}
onClick={() => setShowLabels((v) => !v)}
>
<Tags size={13} /> 标签 {showLabels ? "开" : "关"}
</button>
</div>
</>
);
const hint =
graphMode === "explore" ? (
<div className="kg-canvas-hint">
{!exploreRootId
? "点击任意节点作为起点,开始逐层探索知识关系。"
: "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
</div>
) : null;
return (
<div className="kg-page" ref={pageRef}>
<GraphHeader
nodeCount={displayedGraph.nodes.length}
edgeCount={displayedGraph.edges.length}
totalNodes={allNodes.length}
totalEdges={allEdges.length}
refreshing={isFetching}
onBack={() => 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}
/>
<div
className={[
"kg-body",
showSidebar ? "" : "kg-hide-left",
showDetail ? "" : "kg-hide-right",
]
.filter(Boolean)
.join(" ")}
>
{showSidebar ? (
<GraphSidebar
searchKeyword={searchKeyword}
searchMatchCount={searchMatchIds ? searchMatchIds.size : null}
nodeKindCounts={nodeKindCounts}
edgeLabelCounts={edgeLabelCounts}
disabledNodeKinds={disabledNodeKinds}
disabledEdgeLabels={disabledEdgeLabels}
stats={stats}
topNodes={topNodes}
hiddenCount={hiddenNodeIds.size}
blacklistItems={blacklistItems}
blacklistPending={addToBlacklist.isPending || removeFromBlacklist.isPending}
junkCandidateCount={junkCandidates.length}
onSearchChange={setSearchKeyword}
onSearchEnter={handleSearchEnter}
onToggleNodeKind={handleToggleNodeKind}
onToggleEdgeLabel={handleToggleEdgeLabel}
onRestoreHidden={handleRestoreHidden}
onRemoveBlacklist={handleRemoveBlacklist}
onCleanJunk={handleCleanJunk}
onPickNode={handlePickNode}
onClose={() => setShowSidebar(false)}
/>
) : null}
<div className="kg-canvas-shell">
{!showSidebar ? (
<button
type="button"
className="kg-side-rail-toggle kg-side-rail-toggle-left"
onClick={() => setShowSidebar(true)}
>
筛选
</button>
) : null}
<GraphCanvas
ref={canvasRef}
nodes={displayedGraph.nodes}
edges={displayedGraph.edges}
degrees={degrees}
selectedNodeId={selectedNodeId}
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
theme={themeToBaseMode(theme)}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
explore={
graphMode === "explore"
? {
preview: !exploreRootId,
rootId: exploreRootId,
nodeDepthMap,
expandedNodeIds,
}
: null
}
toolbar={toolbar}
hint={hint}
overlay={overlay}
/>
{!showDetail ? (
<button
type="button"
className="kg-side-rail-toggle kg-side-rail-toggle-right"
onClick={() => setShowDetail(true)}
>
详情
</button>
) : null}
</div>
{showDetail ? (
<GraphDetailPanel
node={selectedNode}
degree={selectedNodeId ? (degrees.get(selectedNodeId) ?? 0) : 0}
neighbors={selectedNeighbors}
note={noteId ? note : undefined}
noteLoading={Boolean(noteId) && noteLoading}
explore={selectedExploreInfo}
onViewSource={handleViewSource}
onLocate={(id) => canvasRef.current?.focusNode(id)}
onHide={handleHideNode}
onBlacklist={handleBlacklistNode}
onPickNeighbor={handlePickNode}
onExpandNode={expandOneHop}
onSetExploreRoot={handleSetExploreRoot}
onClose={() => setShowDetail(false)}
/>
) : null}
</div>
<GraphFooter
nodeKindCounts={nodeKindCounts}
edgeLabels={[...edgeLabelCounts.keys()]}
zoomLevel={zoomLevel}
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
visibleNodes={displayedGraph.nodes.length}
visibleEdges={displayedGraph.edges.length}
/>
<GraphTooltip tooltip={tooltip} />
</div>
);
}