864 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|