perf: reduce graph panel refresh churn during streaming

This commit is contained in:
Youzini-afk
2026-04-11 23:05:07 +08:00
parent b506aaa7c5
commit 898698364e
3 changed files with 408 additions and 27 deletions

View File

@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import {
buildVisibleGraphRefreshToken,
resolveVisibleGraphWorkspaceMode,
} from "../ui/panel-graph-refresh-utils.js";
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: false,
isMobile: false,
currentTabId: "dashboard",
currentGraphView: "graph",
}),
"hidden",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: false,
currentTabId: "config",
currentGraphView: "graph",
}),
"hidden",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: false,
currentTabId: "dashboard",
currentGraphView: "graph",
}),
"desktop:graph",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: false,
currentTabId: "memory",
currentGraphView: "cognition",
}),
"desktop:cognition",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: false,
currentTabId: "actions",
currentGraphView: "summary",
}),
"desktop:summary",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: true,
currentTabId: "dashboard",
currentMobileGraphView: "graph",
}),
"hidden",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: true,
currentTabId: "graph",
currentMobileGraphView: "graph",
}),
"mobile:graph",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: true,
currentTabId: "graph",
currentMobileGraphView: "cognition",
}),
"mobile:cognition",
);
assert.equal(
resolveVisibleGraphWorkspaceMode({
overlayActive: true,
isMobile: true,
currentTabId: "graph",
currentMobileGraphView: "summary",
}),
"mobile:summary",
);
assert.equal(
buildVisibleGraphRefreshToken({
visibleMode: "hidden",
chatId: "chat-main",
loadState: "loaded",
revision: 12,
nodeCount: 40,
edgeCount: 55,
lastProcessedSeq: 9,
}),
"hidden",
);
const baseToken = buildVisibleGraphRefreshToken({
visibleMode: "desktop:graph",
chatId: "chat-main",
loadState: "loaded",
revision: 12,
nodeCount: 40,
edgeCount: 55,
lastProcessedSeq: 9,
});
assert.equal(
baseToken,
buildVisibleGraphRefreshToken({
visibleMode: "desktop:graph",
chatId: "chat-main",
loadState: "loaded",
revision: 12,
nodeCount: 40,
edgeCount: 55,
lastProcessedSeq: 9,
}),
);
assert.notEqual(
baseToken,
buildVisibleGraphRefreshToken({
visibleMode: "desktop:graph",
chatId: "chat-main",
loadState: "loaded",
revision: 13,
nodeCount: 40,
edgeCount: 55,
lastProcessedSeq: 9,
}),
);
assert.notEqual(
baseToken,
buildVisibleGraphRefreshToken({
visibleMode: "desktop:cognition",
chatId: "chat-main",
loadState: "loaded",
revision: 12,
nodeCount: 40,
edgeCount: 55,
lastProcessedSeq: 9,
}),
);
assert.notEqual(
baseToken,
buildVisibleGraphRefreshToken({
visibleMode: "desktop:graph",
chatId: "chat-side",
loadState: "loaded",
revision: 12,
nodeCount: 40,
edgeCount: 55,
lastProcessedSeq: 9,
}),
);
assert.notEqual(
baseToken,
buildVisibleGraphRefreshToken({
visibleMode: "desktop:graph",
chatId: "chat-main",
loadState: "loaded",
revision: 12,
nodeCount: 41,
edgeCount: 55,
lastProcessedSeq: 9,
}),
);
console.log("panel-graph-refresh tests passed");

View File

@@ -0,0 +1,59 @@
export function resolveVisibleGraphWorkspaceMode({
overlayActive = false,
isMobile = false,
currentTabId = "dashboard",
currentGraphView = "graph",
currentMobileGraphView = "graph",
} = {}) {
if (!overlayActive) return "hidden";
if (isMobile) {
if (currentTabId !== "graph") return "hidden";
const mobileView = String(currentMobileGraphView || "graph").trim() || "graph";
return mobileView === "cognition"
? "mobile:cognition"
: mobileView === "summary"
? "mobile:summary"
: "mobile:graph";
}
if (currentTabId === "config") return "hidden";
const desktopView = String(currentGraphView || "graph").trim() || "graph";
return desktopView === "cognition"
? "desktop:cognition"
: desktopView === "summary"
? "desktop:summary"
: "desktop:graph";
}
export function buildVisibleGraphRefreshToken({
visibleMode = "hidden",
chatId = "",
loadState = "",
revision = 0,
nodeCount = -1,
edgeCount = -1,
lastProcessedSeq = -1,
} = {}) {
const normalizedMode = String(visibleMode || "hidden").trim() || "hidden";
if (normalizedMode === "hidden") return "hidden";
const normalizedRevision = Number.isFinite(Number(revision))
? Math.trunc(Number(revision))
: 0;
const normalizedNodeCount = Number.isFinite(Number(nodeCount))
? Math.trunc(Number(nodeCount))
: -1;
const normalizedEdgeCount = Number.isFinite(Number(edgeCount))
? Math.trunc(Number(edgeCount))
: -1;
const normalizedLastProcessedSeq = Number.isFinite(Number(lastProcessedSeq))
? Math.trunc(Number(lastProcessedSeq))
: -1;
return [
normalizedMode,
String(chatId || "").trim(),
String(loadState || "").trim() || "unknown",
normalizedRevision,
normalizedNodeCount,
normalizedEdgeCount,
normalizedLastProcessedSeq,
].join("|");
}

View File

@@ -1,6 +1,10 @@
// ST-BME: 操控面板交互逻辑 // ST-BME: 操控面板交互逻辑
import { GraphRenderer } from "./graph-renderer.js"; import { GraphRenderer } from "./graph-renderer.js";
import {
buildVisibleGraphRefreshToken,
resolveVisibleGraphWorkspaceMode,
} from "./panel-graph-refresh-utils.js";
import { getNodeDisplayName } from "../graph/node-labels.js"; import { getNodeDisplayName } from "../graph/node-labels.js";
import { import {
buildRegionLine, buildRegionLine,
@@ -335,6 +339,12 @@ let fetchedBackendEmbeddingModels = [];
let fetchedDirectEmbeddingModels = []; let fetchedDirectEmbeddingModels = [];
let viewportSyncBound = false; let viewportSyncBound = false;
let popupRuntimePromise = null; let popupRuntimePromise = null;
const GRAPH_LIVE_REFRESH_THROTTLE_MS = 240;
let pendingVisibleGraphRefreshTimer = null;
let pendingVisibleGraphRefreshToken = "";
let pendingVisibleGraphRefreshForce = false;
let lastVisibleGraphRefreshToken = "";
let lastVisibleGraphRefreshAt = 0;
// 由 index.js 注入的引用 // 由 index.js 注入的引用
let _getGraph = null; let _getGraph = null;
@@ -684,6 +694,143 @@ function bindViewportSync() {
window.visualViewport?.addEventListener("scroll", update); window.visualViewport?.addEventListener("scroll", update);
} }
function _getVisibleGraphWorkspaceMode() {
return resolveVisibleGraphWorkspaceMode({
overlayActive: overlayEl?.classList.contains("active") === true,
isMobile: _isMobile(),
currentTabId,
currentGraphView,
currentMobileGraphView,
});
}
function _getCurrentGraphRefreshToken() {
const graph = _getGraph?.();
const persistence = _getGraphPersistenceSnapshot();
return buildVisibleGraphRefreshToken({
visibleMode: _getVisibleGraphWorkspaceMode(),
chatId: persistence?.chatId,
loadState: persistence?.loadState,
revision:
persistence?.revision ??
persistence?.lastAcceptedRevision ??
persistence?.lastSyncedRevision ??
0,
nodeCount: Array.isArray(graph?.nodes) ? graph.nodes.length : -1,
edgeCount: Array.isArray(graph?.edges) ? graph.edges.length : -1,
lastProcessedSeq: graph?.historyState?.lastProcessedAssistantFloor ?? -1,
});
}
function _clearScheduledVisibleGraphRefresh() {
if (pendingVisibleGraphRefreshTimer) {
clearTimeout(pendingVisibleGraphRefreshTimer);
pendingVisibleGraphRefreshTimer = null;
}
pendingVisibleGraphRefreshToken = "";
pendingVisibleGraphRefreshForce = false;
}
function _refreshVisibleGraphWorkspace({ force = false } = {}) {
const visibleMode = _getVisibleGraphWorkspaceMode();
if (visibleMode === "hidden") {
return { refreshed: false, reason: "hidden" };
}
const graph = _getGraph?.();
const nextToken = _getCurrentGraphRefreshToken();
if (!force && nextToken === lastVisibleGraphRefreshToken) {
return { refreshed: false, reason: "unchanged", token: nextToken };
}
const hints = { userPovAliases: _hostUserPovAliasHintsForGraph() };
if (visibleMode === "desktop:graph") {
if (graph && graphRenderer) {
graphRenderer.loadGraph(graph, hints);
}
} else if (visibleMode === "desktop:cognition") {
_refreshCognitionWorkspace();
} else if (visibleMode === "desktop:summary") {
_refreshSummaryWorkspace();
} else if (visibleMode === "mobile:graph") {
if (graph && mobileGraphRenderer) {
mobileGraphRenderer.loadGraph(graph, hints);
}
_buildMobileLegend();
} else if (visibleMode === "mobile:cognition") {
_refreshMobileCognitionFull();
} else if (visibleMode === "mobile:summary") {
_refreshMobileSummaryFull();
}
lastVisibleGraphRefreshToken = nextToken;
lastVisibleGraphRefreshAt = Date.now();
return {
refreshed: true,
reason: force ? "forced" : "changed",
token: nextToken,
visibleMode,
};
}
function _flushScheduledVisibleGraphRefresh() {
const shouldForce = pendingVisibleGraphRefreshForce === true;
_clearScheduledVisibleGraphRefresh();
return _refreshVisibleGraphWorkspace({ force: shouldForce });
}
function _scheduleVisibleGraphWorkspaceRefresh({ force = false } = {}) {
const nextToken = _getCurrentGraphRefreshToken();
if (nextToken === "hidden") {
_clearScheduledVisibleGraphRefresh();
return { scheduled: false, reason: "hidden" };
}
if (force) {
_clearScheduledVisibleGraphRefresh();
return _refreshVisibleGraphWorkspace({ force: true });
}
if (nextToken === lastVisibleGraphRefreshToken) {
return { scheduled: false, reason: "unchanged", token: nextToken };
}
if (
pendingVisibleGraphRefreshTimer &&
pendingVisibleGraphRefreshToken === nextToken &&
pendingVisibleGraphRefreshForce !== true
) {
return { scheduled: true, reason: "pending", token: nextToken };
}
const delay = Math.max(
0,
GRAPH_LIVE_REFRESH_THROTTLE_MS - (Date.now() - lastVisibleGraphRefreshAt),
);
pendingVisibleGraphRefreshToken = nextToken;
pendingVisibleGraphRefreshForce = false;
if (pendingVisibleGraphRefreshTimer) {
clearTimeout(pendingVisibleGraphRefreshTimer);
pendingVisibleGraphRefreshTimer = null;
}
if (delay <= 0) {
return _flushScheduledVisibleGraphRefresh();
}
pendingVisibleGraphRefreshTimer = setTimeout(() => {
_flushScheduledVisibleGraphRefresh();
}, delay);
return {
scheduled: true,
reason: "throttled",
token: nextToken,
delay,
};
}
/** /**
* 初始化面板(由 index.js 调用一次) * 初始化面板(由 index.js 调用一次)
*/ */
@@ -981,7 +1128,6 @@ export function openPanel() {
panelEl?.querySelector(".bme-tab-btn.active")?.dataset.tab || currentTabId; panelEl?.querySelector(".bme-tab-btn.active")?.dataset.tab || currentTabId;
_switchTab(activeTabId); _switchTab(activeTabId);
_refreshRuntimeStatus(); _refreshRuntimeStatus();
_refreshGraph();
_buildLegend(); _buildLegend();
} }
@@ -991,6 +1137,8 @@ export function openPanel() {
export function closePanel() { export function closePanel() {
if (!overlayEl) return; if (!overlayEl) return;
overlayEl.classList.remove("active"); overlayEl.classList.remove("active");
_clearScheduledVisibleGraphRefresh();
lastVisibleGraphRefreshToken = "";
} }
/** /**
@@ -1032,7 +1180,7 @@ export function refreshLiveState() {
_refreshMessageTraceWorkspace(); _refreshMessageTraceWorkspace();
} }
_refreshGraph(); _scheduleVisibleGraphWorkspaceRefresh();
} }
// ==================== Tab 切换 ==================== // ==================== Tab 切换 ====================
@@ -1047,6 +1195,7 @@ function _bindTabs() {
} }
function _switchTab(tabId) { function _switchTab(tabId) {
const previousVisibleGraphMode = _getVisibleGraphWorkspaceMode();
let next = tabId || "dashboard"; let next = tabId || "dashboard";
// 「图谱」仅移动端底部 Tab 可用;桌面端图谱在右侧主工作区,侧栏不设该 Tab // 「图谱」仅移动端底部 Tab 可用;桌面端图谱在右侧主工作区,侧栏不设该 Tab
if (!_isMobile() && next === "graph") { if (!_isMobile() && next === "graph") {
@@ -1078,11 +1227,17 @@ function _switchTab(tabId) {
_refreshConfigTab(); _refreshConfigTab();
break; break;
case "graph": case "graph":
_refreshMobileGraphTab();
break; break;
default: default:
break; break;
} }
const nextVisibleGraphMode = _getVisibleGraphWorkspaceMode();
if (nextVisibleGraphMode !== previousVisibleGraphMode) {
_scheduleVisibleGraphWorkspaceRefresh({ force: true });
} else {
_scheduleVisibleGraphWorkspaceRefresh();
}
} }
function _getPlannerApi() { function _getPlannerApi() {
@@ -1161,8 +1316,7 @@ function _switchGraphView(view) {
if (cogWorkspace) cogWorkspace.style.display = isCognition ? "" : "none"; if (cogWorkspace) cogWorkspace.style.display = isCognition ? "" : "none";
if (summaryWorkspace) summaryWorkspace.style.display = isSummary ? "" : "none"; if (summaryWorkspace) summaryWorkspace.style.display = isSummary ? "" : "none";
if (isCognition) _refreshCognitionWorkspace(); _refreshGraph({ force: true });
if (isSummary) _refreshSummaryWorkspace();
} }
// ==================== 移动端图谱 Tab ==================== // ==================== 移动端图谱 Tab ====================
@@ -1187,14 +1341,7 @@ function _switchMobileGraphSubView(view) {
} }
function _refreshMobileGraphTab() { function _refreshMobileGraphTab() {
if (currentMobileGraphView === "graph") { _refreshGraph({ force: true });
_refreshGraph();
_buildMobileLegend();
} else if (currentMobileGraphView === "cognition") {
_refreshMobileCognitionFull();
} else if (currentMobileGraphView === "summary") {
_refreshMobileSummaryFull();
}
} }
function _buildMobileLegend() { function _buildMobileLegend() {
@@ -2990,20 +3137,8 @@ function _hostUserPovAliasHintsForGraph() {
return getHostUserAliasHints(); return getHostUserAliasHints();
} }
function _refreshGraph() { function _refreshGraph(options = {}) {
const graph = _getGraph?.(); return _refreshVisibleGraphWorkspace({ force: options.force !== false });
if (!graph) return;
const hints = { userPovAliases: _hostUserPovAliasHintsForGraph() };
graphRenderer?.loadGraph(graph, hints);
mobileGraphRenderer?.loadGraph(graph, hints);
if (currentGraphView === "cognition") {
_refreshCognitionWorkspace();
} else if (currentGraphView === "summary") {
_refreshSummaryWorkspace();
}
if (currentTabId === "graph") {
_refreshMobileGraphTab();
}
} }
function _buildLegend() { function _buildLegend() {
@@ -9591,6 +9726,7 @@ function _setText(id, text) {
function _getGraphPersistenceSnapshot() { function _getGraphPersistenceSnapshot() {
return _getGraphPersistenceState?.() || { return _getGraphPersistenceState?.() || {
revision: 0,
loadState: "no-chat", loadState: "no-chat",
reason: "", reason: "",
writesBlocked: true, writesBlocked: true,