feat: 完善知识图谱交互与 Agent/线程持久化
- 知识图谱画布、侧边栏、详情面板交互优化,新增图谱相关 API 与 hooks - Agent 卡片与详情面板调整,对话页面重构 - 线程元数据持久化增强(base/memory/sql),knowledge 仓储与导出补充 - 网关路由 agents/knowledge/threads 同步更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ea78fe9726
commit
f107ebc46d
25 files changed
+929
-279
No files matched your search
@@ -29,7 +29,59 @@ import {
|
||||
} from "@/components/knowledge-graph/graphUtils";
|
||||
import "@/components/knowledge-graph/graphStyles.css";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useKnowledgeGraph, useKnowledgeNote } from "@/core/knowledge/hooks";
|
||||
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();
|
||||
@@ -39,6 +91,8 @@ export default function KnowledgeGraphPage() {
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useKnowledgeGraph();
|
||||
const { data: blacklistItems = [] } = useGraphBlacklist();
|
||||
const blacklistActions = useGraphBlacklistActions();
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
@@ -50,6 +104,7 @@ export default function KnowledgeGraphPage() {
|
||||
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);
|
||||
@@ -134,6 +189,23 @@ export default function KnowledgeGraphPage() {
|
||||
);
|
||||
}, [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());
|
||||
@@ -244,8 +316,8 @@ export default function KnowledgeGraphPage() {
|
||||
]);
|
||||
|
||||
const displayedGraph = useMemo(() => {
|
||||
if (graphMode === "full") return { nodes: filteredNodes, edges: filteredEdges };
|
||||
if (!exploreRootId) return { nodes: filteredNodes, edges: [] };
|
||||
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))),
|
||||
@@ -254,8 +326,8 @@ export default function KnowledgeGraphPage() {
|
||||
allEdges,
|
||||
allNodes,
|
||||
exploreRootId,
|
||||
filteredEdges,
|
||||
filteredNodes,
|
||||
limitedEdges,
|
||||
limitedNodes,
|
||||
graphMode,
|
||||
visibleEdgeIds,
|
||||
visibleNodeIds,
|
||||
@@ -389,6 +461,72 @@ export default function KnowledgeGraphPage() {
|
||||
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) {
|
||||
@@ -532,6 +670,25 @@ export default function KnowledgeGraphPage() {
|
||||
</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
|
||||
@@ -608,11 +765,16 @@ export default function KnowledgeGraphPage() {
|
||||
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)}
|
||||
/>
|
||||
@@ -677,6 +839,7 @@ export default function KnowledgeGraphPage() {
|
||||
onViewSource={handleViewSource}
|
||||
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
||||
onHide={handleHideNode}
|
||||
onBlacklist={handleBlacklistNode}
|
||||
onPickNeighbor={handlePickNode}
|
||||
onExpandNode={expandOneHop}
|
||||
onSetExploreRoot={handleSetExploreRoot}
|
||||
|
||||
Reference in new issue
Block a user