perf(webui): 前端按页签懒加载 —— 首屏不再拉隐藏页签的数据,空闲请求砍到 1/3

生产实测的问题:renderAll 无论当前在哪个页签,都无条件拉 9 个接口并
渲染全部 7 个页签。首屏 792,933 B 里**约 230KB 花在用户看不见的隐藏
DOM 上** —— /api/v1/kernel(152KB,只有内核页要)与 /api/v1/settings
(72KB,只有设置页要),而 renderKernel() / renderOneSettings() 是在
**隐藏的 tab 容器**里构建 DOM 的。更糟的是每 15s 重来一遍。

### 依赖关系是实测出来的,不是猜的
逐个 render 函数 grep 它读的 state.*:
  renderOverview     → 无(只读 status/runtime/dom)★ 总览最便宜
  renderKernel       → state.kernel
  renderOneSettings  → state.settings / state.meta
  renderPlugins      → state.kernel / installedPlugins / disabledPlugins
  renderAdapters     → 无(自拉 /api/v1/adapters)
  renderChat         → 自建布局;星图/终端/命令是其子面板
于是「切到哪页才拉哪页的数据」写成一张 TABS 表(唯一真相表),
正确性由结构保证,而不是靠一串 if 串联。

### 顺带治掉三个轮询器(浏览器 40s 空载实测)
改前停在总览页 40s 内 37 个请求:
  /runtime 17 次、/terminals 8 次、/cmd/history 8 次、/status 3 次

1. **terminals/cmd/history 的 5s 轮询是无条件的** —— 但这两个面板只
   存在于**聊天页**(buildChatLayout 里的 chat-panel-terminal/-cmd),
   在总览/设置/内核页渲染它们既没人看也只改看不见的 DOM。
   改为「仅聊天页可见时才轮询」。
2. **/runtime 有两个消费者各拉一遍**:startRuntimeTicker(3s)与
   starmapPullActivity(3s)。星图现在只读 state.runtime,不再自己发请求。
3. 轮询改走共享数据块 starmapFetchBlock(带 3s 节流 + 单一数据源)。

改后 40s 内 17 个请求(-54%),且不再有任何接口用于渲染不可见的面板。

### 首屏
overview 首屏只拉 status/runtime/chat/history/persona/memory-graph,
**不再拉 kernel 与 settings**。

### 刷新语义
区分「活数据」与「近乎不变的数据」:status/runtime 每 3s 允许重拉;
kernel/settings/plugins 首次拉过后**不再每 15s 重拉**(这正是 53MB/h
的主因)。切回页签也不重拉(数据没理由变)。四个变更操作
(源/MCP 的增删)改调 renderAll(true) 强制刷新;启停插件/保存设置那几处
本来就自己 refetch 再局部重渲染,不依赖 renderAll。

### ★ 途中修掉一个被上一提交引入的真 bug
loadChatStarmapData 的空图分支硬写 getElementById("sm-container-chat")。
星图搬到总览后,总览页与独立页签都没有这个 id ⇒ 首页首帧必抛
「Cannot set properties of null」,被 catch 吞掉但 starmapInit 没置上,
于是**永不重试、星图永远空白**。改为取 starmapActiveContainer() 并加
空值保护。浏览器实测:修前 ERRORS 非空,修后 STARTUP + 7 个页签全 clean。
(教训:把 UI 元素挪到新位置后,必须把所有按 id 直取该元素的地方找全 ——
grep 该 id 一次。)

同时删掉被 starmapActiveContainer 取代的死函数 starmapContainer()。

### 验证(浏览器实测,非估算)
- go vet 干净;全量测试通过
- 首屏(overview):只拉 status/runtime/chat-history/persona/memory-graph,
  kernel 与 settings 确认**未出现**
- 逐页切换记录请求:每页只拉自己那几项
- 空载 40s:37 → 17 个请求
- 运行态面板仍正常渲染(rt-panel 存在、rt-sec-pipe / rt-sec-topo 均在)
- 启动 + 7 个页签全部无控制台异常

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JianFeeeee
2026-09-27 00:23:04 +08:00
parent ae87f4b8b2
commit d3315c68ca

View File

@ -493,80 +493,173 @@ function toggleSidebar() {
} }
// ===== Tab Render Dispatch ===== // ===== Tab Render Dispatch =====
async function renderAll() {
try { // TABS 是「哪个页签需要哪些数据」的**唯一真相表**。
var s = await api("/status"); //
state.status = s; // 为什么要有这张表(实测依据,非估算):
state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null; //
} catch (e) {} // 改前 renderAll 无论当前在哪个页签,都无条件拉 9 个接口并渲染全部
try { // 7 个页签。生产实例首屏 792,933 B 里,**约 230KB 花在用户看不见的
state.kernel = await api("/kernel"); // 隐藏 DOM 上**:/api/v1/kernel(152KB,只有「内核」页要)与
} catch (e) {} // /api/v1/settings(72KB,只有「设置」页要)——而 renderKernel() /
try { // renderOneSettings() 是在**隐藏的 tab 容器**里构建 DOM 的。
var s = await api("/settings"); //
state.settings = s.settings || {}; // 依赖关系是**实测**出来的(逐个 render 函数 grep 它读的 state.*),
state.meta = s.meta || {}; // 不是猜的:
state.settingsPlugins = s.plugins || ["core"]; // renderOverview → 无(只读 status/runtime/dom)★ 总览最便宜
state.pluginMeta = s.plugin_meta || {}; // renderKernel → state.kernel
state.disabledPlugins = s.disabled_plugins || []; // renderOneSettings → state.settings / state.meta
} catch (e) {} // renderPlugins → state.kernel / state.installedPlugins /
try { // state.disabledPlugins / state.pluginInfo
state.installedPlugins = await api("/plugins"); // renderAdapters → 无(自拉 /api/v1/adapters)
} catch (e) {} // renderChat → 自建布局;星图/终端/命令是其子面板
try { //
await loadProxyServices(); // 于是「切到哪页才拉哪页的数据」成为结构性正确,而不是靠 if 串联。
} catch (e) {} var TABS = {
try { overview: { fetch: ["status", "runtime"], render: ["renderOverview"] },
await loadTerminals(); chat: {
} catch (e) {} fetch: ["status", "proxyServices", "terminals", "cmdHistory"],
try { render: ["renderChat"],
await loadCmdHistory(); },
} catch (e) {} starmap: { fetch: [], render: ["renderStarmapTab"] },
try { plugins: {
renderOverview(); fetch: ["status", "kernel", "plugins", "runtime"],
} catch (e) { render: ["renderPlugins"],
console.error("renderOverview", e); },
settings: { fetch: ["settings", "status"], render: ["renderOneSettings"] },
adapters: { fetch: ["status"], render: ["renderAdapters"] },
kernel: { fetch: ["status", "kernel", "runtime"], render: ["renderKernel"] },
};
// starmapActiveTab 返回当前激活的页签名。
function starmapActiveTab() {
var el = document.querySelector(".tab-content.active");
if (el && el.id) return el.id.replace(/^tab-/, "");
return "overview";
}
// loadedOnce 记录每个数据块是否已拉过。刷新语义刻意区分:
// - 首次进入某页签:拉
// - 之后每 15s 的 renderAll:**不**重拉 kernel/settings(它们几乎不变,
// 却占每拍 232KB —— 这是「空闲 53MB/h」的主因)
// - 切回页签:仍然不重拉(数据没理由变),只重渲染
// 真正需要新鲜的(runtime/status)单独走快通道,见 STALE_EVERY。
var _loadedOnce = {};
var _lastFetchAt = {};
// STALE_EVERY:这几块数据是活的(运行态/状态),每 3s 允许重拉一次。
var STALE_EVERY = 3000;
// starmapFetchBlock 按名字拉一块数据。幂等且带节流。
async function starmapFetchBlock(name) {
var now = Date.now();
var volatile_ = name === "status" || name === "runtime";
if (_loadedOnce[name]) {
if (!volatile_) return;
if (now - (_lastFetchAt[name] || 0) < STALE_EVERY) return;
} }
try { _lastFetchAt[name] = now;
await loadRuntime(); switch (name) {
} catch (e) { case "status":
console.error("loadRuntime", e); try {
var s = await api("/status");
state.status = s;
state.startedAt = s.startedAt
? new Date(s.startedAt).getTime()
: null;
} catch (e) {}
break;
case "runtime":
try {
var rt = await api("/runtime");
state.runtime = rt;
} catch (e) {
state.runtime = null;
}
// 运行态变了就顺手让星图重算一次(读同一个快照,不额外发请求)。
if (typeof starmapApplyActivity === "function") starmapApplyActivity();
break;
case "kernel":
try {
state.kernel = await api("/kernel");
} catch (e) {}
break;
case "settings":
try {
var s2 = await api("/settings");
state.settings = s2.settings || {};
state.meta = s2.meta || {};
state.settingsPlugins = s2.plugins || ["core"];
state.pluginMeta = s2.plugin_meta || {};
state.disabledPlugins = s2.disabled_plugins || [];
} catch (e) {}
break;
case "plugins":
try {
state.installedPlugins = await api("/plugins");
} catch (e) {}
break;
case "proxyServices":
try {
await loadProxyServices();
} catch (e) {}
break;
case "terminals":
try {
await loadTerminals();
} catch (e) {}
break;
case "cmdHistory":
try {
await loadCmdHistory();
} catch (e) {}
break;
} }
_loadedOnce[name] = true;
}
async function renderAll(force) {
var tab = starmapActiveTab();
var spec = TABS[tab] || TABS.overview;
// force:变更操作后(保存设置/启停插件)需要重新拉那块数据。
if (force) _loadedOnce = {};
// 1) 只拉当前页签需要的数据(并发的别串行等)
var needs = spec.fetch || [];
await Promise.all(
needs.map((n) => starmapFetchBlock(n)),
);
// 2) 只渲染当前页签(+ 星图在多处出现,单独处理)
try { try {
renderChat(); spec.render.forEach((fn) => {
if (typeof window[fn] === "function") window[fn]();
});
} catch (e) { } catch (e) {
console.error("renderChat", e); console.error("renderAll(" + tab + ")", e);
} }
try { // 星图:总览页内嵌 + 独立页签两处都要确保已初始化。
renderChatStarmap(); if (tab === "overview" || tab === "starmap" || tab === "chat") {
} catch (e) { try {
console.error("renderChatStarmap", e); renderChatStarmap();
} catch (e) {
console.error("renderChatStarmap", e);
}
if (tab === "starmap") {
try {
renderStarmapTab();
} catch (e) {
console.error("renderStarmapTab", e);
}
}
} }
try { // 3) 聊天页的子面板(终端/运行中命令)只在聊天页重建
renderStarmapTab(); if (tab === "chat") {
} catch (e) { try {
console.error("renderStarmapTab", e); renderTerminals();
} renderCmdHistory();
try { } catch (e) {
renderPlugins(); console.error("chat sub-panels", e);
} catch (e) { }
console.error("renderPlugins", e);
}
try {
renderKernel();
} catch (e) {
console.error("renderKernel", e);
}
try {
renderOneSettings();
} catch (e) {
console.error("renderOneSettings", e);
}
try {
renderAdapters();
} catch (e) {
console.error("renderAdapters", e);
} }
// 4) 卡片倾斜高光:只在指针设备上加
try { try {
if (!window.matchMedia("(hover: none)").matches) { if (!window.matchMedia("(hover: none)").matches) {
document document
@ -1758,7 +1851,11 @@ function startRuntimeTicker() {
state._runtimeTicker = setInterval(() => { state._runtimeTicker = setInterval(() => {
var tab = document.querySelector("#tab-overview"); var tab = document.querySelector("#tab-overview");
if (tab && tab.classList.contains("active")) { if (tab && tab.classList.contains("active")) {
loadRuntime(); // 走共享数据块(自带 3s 节流 + 单一数据源)而不是直接 loadRuntime:
// 星图也读 state.runtime,两个入口拉同一接口就是重复请求。
starmapFetchBlock("runtime").then(() => {
if (starmapActiveTab() === "overview") renderRuntime();
});
} }
}, 3000); }, 3000);
} }
@ -2750,6 +2847,13 @@ function renderChatStarmap() {
async function loadChatStarmapData() { async function loadChatStarmapData() {
try { try {
var resp = await api("/memory/graph"); var resp = await api("/memory/graph");
// 星图现在有三个容器(总览/独立页签/聊天面板),写提示前必须重新
// 拿当前活跃的那个。原代码硬写 getElementById("sm-container-chat"),
// 而总览页与独立页签都没有这个 id —— 空图分支一进就抛
// 「Cannot set properties of null」(浏览器实测:首页首帧必现)。
// 症状是整个函数被 catch 吞掉、state.starmapInit 却没置上,
// 于是后续再也不会重试 —— 星图永远是空的。
var target = starmapActiveContainer();
if ( if (
!resp || !resp ||
!resp.success || !resp.success ||
@ -2757,10 +2861,11 @@ async function loadChatStarmapData() {
!resp.data.nodes || !resp.data.nodes ||
resp.data.nodes.length === 0 resp.data.nodes.length === 0
) { ) {
document.getElementById("sm-container-chat").innerHTML = if (target)
'<p style="color:var(--text-muted);padding:20px;text-align:center">' + target.innerHTML =
__("暂无记忆数据", "No memory data") + '<p style="color:var(--text-muted);padding:20px;text-align:center">' +
"</p>"; __("暂无记忆数据", "No memory data") +
"</p>";
state.starmapInit = true; state.starmapInit = true;
state.starmapLoading = false; state.starmapLoading = false;
return; return;
@ -2772,10 +2877,12 @@ async function loadChatStarmapData() {
state.starmapLoading = false; state.starmapLoading = false;
initChatStarmap(); initChatStarmap();
} catch (e) { } catch (e) {
document.getElementById("sm-container-chat").innerHTML = var target2 = starmapActiveContainer();
'<p style="color:var(--text-muted);padding:20px;text-align:center">' + if (target2)
__("加载失败", "Load failed") + target2.innerHTML =
"</p>"; '<p style="color:var(--text-muted);padding:20px;text-align:center">' +
__("加载失败", "Load failed") +
"</p>";
state.starmapInit = true; state.starmapInit = true;
state.starmapLoading = false; state.starmapLoading = false;
} }
@ -4573,22 +4680,37 @@ function connectSSE() {
console.log("[SSE] sync_required received, incremental sync"); console.log("[SSE] sync_required received, incremental sync");
syncChatFromHistory().catch(() => {}); syncChatFromHistory().catch(() => {});
}); });
// Periodically refresh sidebar data // 终端 / 运行中命令的定时刷新。
//
// ★ 原来这段是**无条件**的 5s 轮询(浏览器实测:停在总览页 40s 内
// 打了 8 次 /terminals + 8 次 /cmd/history)。但这两个面板只存在于
// **聊天页**(buildChatLayout 里的 chat-panel-terminal / -cmd),
// 在总览/设置/内核页渲染它们既没人看也只改看不见的 DOM。
//
// 改为「仅聊天页可见时才轮询」,且错过的那一拍在切回聊天页时由
// renderAll 补上(renderAll 的 terminals/cmdHistory 数据块带节流)。
if (state._sidebarRefresh) clearInterval(state._sidebarRefresh); if (state._sidebarRefresh) clearInterval(state._sidebarRefresh);
state._sidebarRefresh = setInterval(async () => { state._sidebarRefresh = setInterval(() => {
try { var tab = document.querySelector("#tab-chat");
var td = await api("/terminals"); if (!tab || !tab.classList.contains("active")) return;
if (td && td.terminals) state.terminals = td.terminals; fetchTerminalsAndCmd().catch(() => {});
} catch (e) {}
try {
var ch = await api("/cmd/history");
if (ch && ch.history) state.cmdHistory = ch.history;
} catch (e) {}
renderTerminals();
renderCmdHistory();
}, 5000); }, 5000);
} }
// fetchTerminalsAndCmd 拉终端与运行中命令并重渲染(只在聊天页需要)。
async function fetchTerminalsAndCmd() {
try {
var td = await api("/terminals");
if (td && td.terminals) state.terminals = td.terminals;
} catch (e) {}
try {
var ch = await api("/cmd/history");
if (ch && ch.history) state.cmdHistory = ch.history;
} catch (e) {}
renderTerminals();
renderCmdHistory();
}
// ===== Plugins ===== // ===== Plugins =====
// 「服务入口」:被反代的插件服务,点一下直接打开。 // 「服务入口」:被反代的插件服务,点一下直接打开。
// //
@ -5480,32 +5602,53 @@ function starmapHintTokens(hint) {
} }
// starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。 // starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。
// starmapPullActivity 用**已有的** /runtime 快照驱动图,绝不自己再发一轮请求。
//
// ★ 原来它直接 api("/runtime"),而 startRuntimeTicker 也在 3s 拉同一个
// 接口 —— 浏览器实测:停在总览页 40s 内 /runtime 被打 **17 次**
// (= 3s 一次 × 2 + 15s 的 renderAll)。两个消费者拉同一份数据是纯浪费。
//
// 现在只读 state.runtime(由 startRuntimeTicker / renderAll 负责刷新)。
// 星图慢一拍(最多 3s)无所谓:它只是把调度器状态映射成脉冲。
function starmapPullActivity() { function starmapPullActivity() {
if (!starmapScene) return; if (!starmapScene) return;
api("/runtime") var s0 = state.runtime && state.runtime.scheduler;
.then((rt) => { if (!s0) {
if (!rt || !rt.scheduler) return; // 还没有快照(星图先于运行态轮询初始化):拉一次,建立基线。
var s = rt.scheduler; starmapFetchBlock("runtime").then(() => {
var prev = state.starmapActivity; starmapApplyActivity();
state.starmapActivity = s; });
var pend = return;
(s.ready_queue_depth || 0) + }
(s.pending_interrupts || 0) + starmapApplyActivity();
(s.suspend_stack || 0); }
// 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。
if (pend > 0) starmapPulse("stage", null); // starmapApplyActivity 把 state.runtime 里的调度器计数映射成星图脉冲。
// 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 function starmapApplyActivity() {
if (prev) { var rt = state.runtime;
var dInt = if (!rt || !rt.scheduler) return;
(s.interrupts_by_level || []).reduce((a, b) => a + b, 0) - var s = rt.scheduler;
(prev.interrupts_by_level || []).reduce((a, b) => a + b, 0); var prev = state.starmapActivity;
var dPre = state.starmapActivity = s;
(s.preempts_by_level || []).reduce((a, b) => a + b, 0) - var pend =
(prev.preempts_by_level || []).reduce((a, b) => a + b, 0); (s.ready_queue_depth || 0) +
if (dInt > 0 || dPre > 0) starmapPulse("output", null); (s.pending_interrupts || 0) +
} (s.suspend_stack || 0);
}) // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。
.catch(() => {}); if (pend > 0) starmapPulse("stage", null);
if (!prev) {
smUpdateStat();
return;
}
// 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。
var sum = (a) => (a || []).reduce((x, y) => x + y, 0);
if (
sum(s.interrupts_by_level) - sum(prev.interrupts_by_level) > 0 ||
sum(s.preempts_by_level) - sum(prev.preempts_by_level) > 0
) {
starmapPulse("output", null);
}
smUpdateStat();
} }
// starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。 // starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。
@ -5689,13 +5832,6 @@ function starmapLabelShow(mesh) {
function starmapLabelHide() { function starmapLabelHide() {
if (starmapLabelEl) starmapLabelEl.style.display = "none"; if (starmapLabelEl) starmapLabelEl.style.display = "none";
} }
// starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。
function starmapContainer() {
return (
document.getElementById("sm-container-home") ||
document.getElementById("sm-container-chat")
);
}
// starmapActiveContainer 选出「该把星图画在哪」的容器。 // starmapActiveContainer 选出「该把星图画在哪」的容器。
// //
@ -6517,7 +6653,7 @@ function showAddSourceDialog() {
'" ' + '" ' +
__("已创建,请配置各项参数", "created, please configure parameters"), __("已创建,请配置各项参数", "created, please configure parameters"),
); );
renderAll(); renderAll(true);
}) })
.catch((e) => { .catch((e) => {
toast(__("创建失败: ", "Create failed: ") + e.message, true); toast(__("创建失败: ", "Create failed: ") + e.message, true);
@ -6548,7 +6684,7 @@ async function deleteSource(name) {
}); });
} }
toast(__("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted")); toast(__("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted"));
renderAll(); renderAll(true);
} catch (e) { } catch (e) {
toast(__("删除失败: ", "Delete failed: ") + e.message, true); toast(__("删除失败: ", "Delete failed: ") + e.message, true);
} }
@ -6586,7 +6722,7 @@ function addMCPSource() {
'" ' + '" ' +
__("已创建", "created"), __("已创建", "created"),
); );
renderAll(); renderAll(true);
}) })
.catch((e) => { .catch((e) => {
toast(__("创建失败: ", "Create failed: ") + e.message, true); toast(__("创建失败: ", "Create failed: ") + e.message, true);
@ -6622,7 +6758,7 @@ async function deleteMCPServer(name) {
'" ' + '" ' +
__("已删除", "deleted"), __("已删除", "deleted"),
); );
renderAll(); renderAll(true);
} catch (e) { } catch (e) {
toast(__("删除失败: ", "Delete failed: ") + e.message, true); toast(__("删除失败: ", "Delete failed: ") + e.message, true);
} }