perf(starmap): WebUI/GUI 星图卡顿修复 + 抽出共享「布局数学」单一源

## 症状与实测

WebUI 星图建图阻塞主线程 9339ms、draw calls 7514;GUI 建图 2466ms、
draw calls 3477。两者在空闲时仍在空转渲染。

## WebUI(internal/plugins/webui/dashboard.js)

- 力导向布局 O(n²) 两两互斥改**网格近邻**,并移入 **Web Worker**
  ⇒ 建图阻塞 **9339ms → 57ms**
- 每节点光晕 3753 个 draw call → 1 个 InstancedMesh
- 边 966 → 4 个 LineSegments
- 空闲帧跳过 + 星空时间驱动
- 结果:draw calls **7514 → 3760**,空闲阻塞 **99% → 3%**

## GUI(cmd/gui/renderer/app.js)

- draw calls **3477 → 1809**,建图 **2466ms → 943ms**
- 首屏可见性守卫(GUI 默认 chat 视图,星图不该在后台初始化)
- 空闲 12fps → 0.2fps

修 GUI 两个自引 bug:
  1. 抄 WebUI 的 `starmapMorphSet`(GUI 实际用 `state.starmapGrown`)⇒
     每帧 ReferenceError、星图 0 帧;
  2. `camKey` 读 `starmapCam.position` 无守卫(`switchChatPanel` 同步调
     `starmapAnimate` 时 cam 为 nil)。**同一缺陷 WebUI 也存在,两处均已补守卫。**

## 共享源(方案 A:只共享「布局数学」这一层)

新增 `shared/starmap/layout.js`(3 个纯函数,零 DOM/全局引用)+
生成器 `tools/starmap-sync.mjs` + 门禁 `make check-starmap-shared`(已进
make test)。三处(共享源 / dashboard.js / app.js)的标记块必须逐字节相同。

**为何只共享这一层**(实测依据):web 星图 54 个函数、gui 24 个,两者同名
函数逐字节相同数为 **0**。GUI 有设备桥/computer use/托盘/自有面板,WebUI 有
侧栏页签体系 —— 共享受众完全不同的 UI 代码是错的,能安全共享的只有布局数学。

**为何用生成器而非运行时加载**:GUI 用 file:// (main.js 的 loadFile),可能
离线;让其拉服务端 dashboard.js 会把「星图能否初始化」绑到网络与登录态上。

**为何 GUI 不用 Web Worker**:file:// 下外链与 Blob Worker 实测都能跑,但不把
「星图能否初始化」押在打包环境的 Worker 行为上。

## 判据

- `cmd/gui/starmap-perf.test.mjs`(21 条,接入 npm test)
- 扩充 `internal/plugins/webui/starmap_perf_test.go`(同源判据 +
  checkStarmapSharedBlock)
- 变异 M1/M2/M3 全部报红

## 附带

cmd/gui/renderer/app.js 同时含上一提交的 GUI `system` 事件消费(协议修复),
因同文件交织未再拆分,见提交 fix(clients)。
This commit is contained in:
JianFeeeee
2026-10-13 16:21:04 +08:00
parent 50ef27891f
commit cf3d3769b1
8 changed files with 1086 additions and 106 deletions

View File

@ -2692,6 +2692,21 @@ function renderChatStarmap() {
return;
}
if (state.starmapLoading) return;
// ★★ 2026-10-13:星图面板不可见时不建图。
//
// 为何必须在这里拦:GUI 的**默认视图就是 chat**(state.currentView = "chat"),
// 而 renderAll 在 chat 视图会调 renderChatStarmap()。此前该函数无可见性守卫 ⇒
// 每次启动都为**看不见的**星图面板付全价:实测 943ms 建图 + 首帧 433ms
// 纹理上传(1294 张)≈ 1.4 秒的白耗——用户第一眼看到的却是聊天页。
//
// 判据用 offsetParent:面板 display:none 或未挂载时为 null。
// 等用户真点到「星图」子面板,switchChatPanel 会再调本函数。
var visCheck = document.getElementById("sm-container-chat");
if (!visCheck || !visCheck.offsetParent) {
state.starmapLoading = false;
state.starmapInit = false;
return;
}
state.starmapLoading = true;
loadChatStarmapData();
}
@ -3065,35 +3080,269 @@ function initChatStarmap() {
} catch (e) {}
starmapAnimate();
}
// ===== BEGIN shared:starmap/layout =====
// 星图力导向布局 —— WebUI 与 GUI 的**唯一事实源**。
//
// ===================================================================
// 这个文件为什么存在(别把它当成“又一份副本”)
// ===================================================================
//
// 下面这三个函数原本在 `internal/plugins/webui/dashboard.js` 与
// `cmd/gui/renderer/app.js` 里各有一份手抄。分叉的代价已经兑现过两次:
//
// 1. WebUI 把 O(n²) 布局换成网格近邻(生产库 3754 块:3.52 亿次配对 →
// n·k 次),GUI 那份没跟着改 ⇒ 换客户端卡顿原样存在。
// 2. 把空闲帧跳过从 WebUI 抄进 GUI 时抄了 `starmapMorphSet`(WebUI 独有),
// GUI 实际用 `state.starmapGrown` ⇒ 渲染循环每帧 ReferenceError、星图 0 帧。
//
// 所以这里放**唯一一份**实现,由 `tools/starmap-sync.mjs` 写进两个客户端文件
// 的 `BEGIN/END shared:starmap/layout` 标记块内,并由判据钉住“三处逐字节相同”。
//
// ===================================================================
// 算法
// ===================================================================
//
// 被替换掉的旧实现是 50 轮 `for i / for j=i+1` 全对全斥力:
// 生产库 3754 块 ⇒ 每轮 704 万次配对、50 轮 3.52 亿次。实测(CDP Profiler
// + Electron)单次建图阻塞主线程 1.8–10.9 秒。
//
// 关键观察:原斥力体里本来就有 `if (d < 25)` —— **只对近邻施力**。既然只关心
// 近邻,就没必要算全对全:把空间切成边长 == 斥力半径的格子,每个节点只与
// 「自身格 + 相邻 26 格」(3×3×3)内的点比较即可。
//
// ★ 为何格边长必须恰等于斥力半径:这样「距离 < 25 的点」必然落在相邻 26 格内,
// **绝不遗漏** —— 这是敢跳过远处配对的唯一依据。格边长一旦小于斥力半径,
// 就会漏掉真实邻居,图会塌成一团。
//
// 复杂度 O(n·k):均匀分布下每格约 1 点 ⇒ 每轮约 27n 次比较。
// 配合迭代数自适应与收敛早停,3754 节点实测 8365ms → 438ms。
// ★ 几何体/材质共享(2026-10-05 修星图内存暴涨)
// 自适应迭代数:小图跑满 50 轮(值得精细),大图退到 14 轮。
//
// 原实现每个节点都`new THREE.SphereGeometry(gr, 16, 12)`——因为半径
// gr 各不相同,看起来「只能各自 new」。但 three.js 里几何体的大小应当由
// **mesh.scale** 表达,几何体本身应当共享。
// n=3754 时约 15 轮。旧实现固定 50 轮,且每轮是 O(n²)。
// 下限 14 是观感值:网格法下每轮很便宜(n·k 而非 n²),轮数多一些布局更舒展;
// 再少会让叶节点分布明显不均(母节点周围挤堆)。
function starmapLayoutIterationsFor(n) {
if (n <= 400) return 50;
var it = Math.floor(60000 / n);
if (it < 14) it = 14;
if (it > 50) it = 50;
return it;
}
// 早停阈值:取「节点平均间距的 0.2%」。
// 用相对量而不是绝对值,否则同一个阈值在不同规模的图上含义完全不同。
function starmapLayoutMaxMoveThreshold(n, maxR) {
var perimeter = 2 * Math.PI * maxR;
var gap = n > 0 ? perimeter / n : maxR;
var th = gap * 0.002;
var cap = maxR * 2 * 0.001;
return th < cap ? th : cap;
}
// starmapLayoutRelax 就地松弛坐标。
//
// 实测(CDP 抓生产实例,2588 个节点):
//
// Mesh/SphereGeometry 2588 个 geometry 数据合计 22.67MB
// Sprite/BufferGeometry 1294 个(label 纹理,每个 256×64 RGBA ≈ 64KB)
//
// 两者都不释放:buildChatStarmapGraph 只starmapScene.remove(m),
// **从不 dispose()** —— remove 只把对象从场景树摘下来,WebGL 侧资源
// (buffer/texture)仍被引用,不释放。星图每次全量重建泄漏一整轮,
// 于是进程常驻内存反复冲上GB 级、靠 GC 回收后再度暴涨,界面随之卡住。
//
// 修法两条:单位球共享(几何体从 2588 份降到 1 份)+ 重建时 dispose。
// 单位球细节:SphereGeometry(1, 16, 12) 建一次,实例按 gr 缩放,
// 因为 mesh 是父节点,缩放会叠加到子 glow 上—— 所以 glow 挂到 mesh 下时
// 要用 gr/rad 的相对比例。
// ★ 单位球几何体的**半径取0.5**,不是 1。
//
// 原因:节点缩放基准是 mesh.userData.baseScale = rad / 0.5 ——
// 分母0.5 就是原SphereGeometry 的半径。若把共享球改成半径 1,
// baseScale 会整体缩放一倍(大的节点变得更大、小的更小),且与
// 脉冲结束时的复原逻辑(setScalar(baseScale))不自洽。
// 保持 0.5 ⇒ baseScale 公式原样成立,行为与修复前逐像素一致。
// 入参/出参见调用方:flat 是扁平 Float64Array(长度 3n),
// nodes 提供 id 与 mention_count(权重),edges 提供 source_id/target_id。
// 用扁平数组而非对象数组,是为了让可行时能直接走结构化克隆交给 Worker。
function starmapLayoutRelax(flat, nodes, edges, maxR) {
var n = nodes.length;
if (n === 0) return flat;
var CUTOFF = 25; // == 斥力半径,见文件头说明
var BASE_R = 15;
// 归一化权重(斥力/吸力都按它加权)
var mn = new Float64Array(n);
var maxMc = 1,
minMc = 0;
for (var a = 0; a < n; a++) {
var m = (nodes[a] && nodes[a].mention_count) || 0;
if (m > maxMc) maxMc = m;
if (a === 0 || m < minMc) minMc = m;
}
var rng = maxMc - minMc || 1;
for (var b = 0; b < n; b++) {
mn[b] = (((nodes[b] && nodes[b].mention_count) || 0) - minMc) / rng;
}
// 边下标(id → 下标对)。下标化是为了内层循环不做字符串比较。
var idx = {};
for (var i0 = 0; i0 < n; i0++) idx[nodes[i0].id] = i0;
var edgeIdx = [];
for (var e0 = 0; e0 < edges.length; e0++) {
var s0 = idx[edges[e0].source_id],
t0 = idx[edges[e0].target_id];
if (s0 === undefined || t0 === undefined || s0 === t0) continue;
edgeIdx.push([s0, t0]);
}
var grid = new Map();
function rebuildGrid() {
grid.clear();
for (var i = 0; i < n; i++) {
var key =
Math.floor(flat[i * 3] / CUTOFF) +
"|" +
Math.floor(flat[i * 3 + 1] / CUTOFF) +
"|" +
Math.floor(flat[i * 3 + 2] / CUTOFF);
var bucket = grid.get(key);
if (!bucket) grid.set(key, (bucket = []));
bucket.push(i);
}
}
var iters = starmapLayoutIterationsFor(n);
var th = starmapLayoutMaxMoveThreshold(n, maxR);
var shadow = new Float64Array(flat.length);
shadow.set(flat);
for (var it = 0; it < iters; it++) {
rebuildGrid();
// 斥力:只算 3×3×3 邻格内的近邻;每对只处理一次(j > i),
// 与旧实现 `for j = i+1` 的语义一致,避免重复施力。
for (var i1 = 0; i1 < n; i1++) {
var cxi = Math.floor(flat[i1 * 3] / CUTOFF);
var cyi = Math.floor(flat[i1 * 3 + 1] / CUTOFF);
var czi = Math.floor(flat[i1 * 3 + 2] / CUTOFF);
for (var dx = -1; dx <= 1; dx++) {
for (var dy = -1; dy <= 1; dy++) {
for (var dz = -1; dz <= 1; dz++) {
var bk = grid.get(cxi + dx + "|" + (cyi + dy) + "|" + (czi + dz));
if (!bk) continue;
for (var bi = 0; bi < bk.length; bi++) {
var j = bk[bi];
if (j <= i1) continue;
var xi = flat[i1 * 3],
yi = flat[i1 * 3 + 1],
zi = flat[i1 * 3 + 2];
var ex = xi - flat[j * 3];
var ey = yi - flat[j * 3 + 1];
var ez = zi - flat[j * 3 + 2];
var dd = Math.sqrt(ex * ex + ey * ey + ez * ez) + 0.1;
if (dd >= CUTOFF) continue;
var rf = 0.5 + (mn[i1] + mn[j]) * 0.5;
var force = (0.06 * rf) / Math.max(dd, 0.5);
var fx = (ex / dd) * force,
fy = (ey / dd) * force,
fz = (ez / dd) * force;
flat[i1 * 3] += fx;
flat[i1 * 3 + 1] += fy;
flat[i1 * 3 + 2] += fz;
flat[j * 3] -= fx;
flat[j * 3 + 1] -= fy;
flat[j * 3 + 2] -= fz;
}
}
}
}
}
// 吸力(沿边,线性)
for (var e2 = 0; e2 < edgeIdx.length; e2++) {
var s2 = edgeIdx[e2][0],
t2 = edgeIdx[e2][1];
var ax = flat[s2 * 3],
ay = flat[s2 * 3 + 1],
az = flat[s2 * 3 + 2];
var bx = flat[t2 * 3],
by = flat[t2 * 3 + 1],
bz = flat[t2 * 3 + 2];
var gx = bx - ax,
gy = by - ay,
gz = bz - az;
var gd = Math.sqrt(gx * gx + gy * gy + gz * gz) + 0.1;
if (gd <= 20) continue;
var af = 0.04 * Math.max(0.3, 1.0 - (mn[s2] + mn[t2]) * 0.3);
var ux = (gx / gd) * af,
uy = (gy / gd) * af,
uz = (gz / gd) * af;
flat[s2 * 3] += ux;
flat[s2 * 3 + 1] += uy;
flat[s2 * 3 + 2] += uz;
flat[t2 * 3] -= ux;
flat[t2 * 3 + 1] -= uy;
flat[t2 * 3 + 2] -= uz;
}
// 向心约束 + 早停位移统计(一次遍历同时做完,省一趟内存读)
var maxA = maxR * 1.5;
var maxMove = 0;
for (var i3 = 0; i3 < n; i3++) {
var x3 = flat[i3 * 3],
y3 = flat[i3 * 3 + 1],
z3 = flat[i3 * 3 + 2];
var dist = Math.sqrt(x3 * x3 + y3 * y3 + z3 * z3);
if (dist > maxA) {
var sc = maxA / dist;
x3 *= sc;
y3 *= sc;
z3 *= sc;
flat[i3 * 3] = x3;
flat[i3 * 3 + 1] = y3;
flat[i3 * 3 + 2] = z3;
}
var mvx = x3 - shadow[i3 * 3];
if (mvx < 0) mvx = -mvx;
var mvy = y3 - shadow[i3 * 3 + 1];
if (mvy < 0) mvy = -mvy;
var mvz = z3 - shadow[i3 * 3 + 2];
if (mvz < 0) mvz = -mvz;
var mv = mvx > mvy ? mvx : mvy;
if (mvz > mv) mv = mvz;
if (mv > maxMove) maxMove = mv;
}
shadow.set(flat);
if (maxMove < th) break; // 收敛:剩余轮次只是重复同一状态
}
return flat;
}
// 本块由 tools/starmap-sync.mjs 从 shared/starmap/layout.js 生成 —— 请改共享源,不要直接改这里。
// ===== END shared:starmap/layout =====
var starmapUnitSphere = null;
// starmapGlowMesh:全部节点的光晕合并成的一个 InstancedMesh,以及它的构建参数。
//
// 为何合并(2026-10-13):原每节点一个光晕子 mesh(1294 个),使 draw call
// 达 3477/帧。光晕是纯静态装饰(glowSphere 全仓只有写入、从无读取)。
var starmapGlowMesh = null;
var starmapGlowItems = [];
// starmapBuildGlowInstances 把全部节点的光晕球合并成一个 InstancedMesh。
//
// ★ 半径换算:主球 mesh 是按 baseScale(=rad/0.5) 缩放的,而光晕现在脱离了
// 该缩放链,所以必须用**世界半径**直接调 scale:共享单位球半径 0.5 ⇒
// scale = worldRadius / 0.5。原先子 mesh 的相对换算 (gr/parentScale)*2
// 在这里不适用(那是为挂在缩放过的父节点上算的)。
function starmapBuildGlowInstances(items, pos) {
if (!items.length) return;
var unit = starmapSphereGeometry();
var gm = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0.16,
side: THREE.BackSide,
blending: THREE.AdditiveBlending,
});
var inst = new THREE.InstancedMesh(unit, gm, items.length);
inst.instanceMatrix.setUsage(THREE.StaticDrawUsage);
var dummy = new THREE.Object3D();
var c = new THREE.Color();
for (var i = 0; i < items.length; i++) {
var it = items[i];
var p = pos[it.id];
if (!p) {
dummy.position.set(0, 0, 0);
dummy.scale.setScalar(0);
} else {
dummy.position.set(p.x, p.y, p.z);
dummy.scale.setScalar(it.worldRadius / 0.5);
}
dummy.updateMatrix();
inst.setMatrixAt(i, dummy.matrix);
inst.setColorAt(i, c.setHex(it.color));
}
inst.instanceMatrix.needsUpdate = true;
if (inst.instanceColor) inst.instanceColor.needsUpdate = true;
inst.frustumCulled = false;
starmapScene.add(inst);
starmapGlowMesh = inst;
starmapGlowItems = items;
}
function starmapSphereGeometry() {
if (!starmapUnitSphere) starmapUnitSphere = new THREE.SphereGeometry(0.5, 16, 12);
return starmapUnitSphere;
@ -3161,6 +3410,14 @@ function buildChatStarmapGraph() {
});
starmapNodeMeshes = [];
starmapEdgeLines = [];
// ★ 光晕实例要一并释放(同一次重建里它会被重建)。
if (starmapGlowMesh) {
starmapScene.remove(starmapGlowMesh);
if (starmapGlowMesh.material) starmapGlowMesh.material.dispose();
// geometry 是共享单位球(starmapSphereGeometry),不能 dispose。
starmapGlowMesh = null;
}
var glowItems = [];
if (starmapNodes.length === 0) return;
// Calculate node degrees for leaf node detection
var nodeDegs = {};
@ -3228,61 +3485,33 @@ function buildChatStarmapGraph() {
};
});
// Force-directed simulation
for (var it = 0; it < 50; it++) {
var ids = Object.keys(pos);
// Repulsion
for (var i = 0; i < ids.length; i++) {
for (var j = i + 1; j < ids.length; j++) {
var a = pos[ids[i]],
b = pos[ids[j]];
var dx = a.x - b.x,
dy = a.y - b.y,
dz = a.z - b.z,
d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1;
var rf = 0.5 + (a.mn + b.mn) * 0.5;
if (d < 25) {
var force = (0.06 * rf) / Math.max(d, 0.5);
a.x += (dx / d) * force;
a.y += (dy / d) * force;
a.z += (dz / d) * force;
b.x -= (dx / d) * force;
b.y -= (dy / d) * force;
b.z -= (dz / d) * force;
}
}
}
// Attraction along edges
starmapEdges.forEach((e) => {
var a = pos[e.source_id],
b = pos[e.target_id];
if (!a || !b) return;
var dx = b.x - a.x,
dy = b.y - a.y,
dz = b.z - a.z,
d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1;
var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3);
if (d > 20) {
var force = 0.04 * af;
a.x += (dx / d) * force;
a.y += (dy / d) * force;
a.z += (dz / d) * force;
b.x -= (dx / d) * force;
b.y -= (dy / d) * force;
b.z -= (dz / d) * force;
}
});
// Centering constraint
ids.forEach((id) => {
var p = pos[id];
var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z);
var maxA = maxR * 1.5;
if (dist > maxA) {
var s = maxA / dist;
p.x *= s;
p.y *= s;
p.z *= s;
}
});
//
// ★★ 2026-10-13:O(n²) → O(n·k) 网格法(与 WebUI dashboard.js 同一算法)。
//
// 旧实现在此内联跑 50 轮 `for i / for j=i+1` 全对全斥力。实测(Electron
// + 真生产库 3754 块):**单次建图阻塞主线程 1.8–9 秒**。而斥力体里本来
// 就有 `if (d < 25)` —— 只对近邻施力。既然只关心近邻,全对全就是白算:
// 把空间切成边长 == 斥力半径的格子,每点只比自身格 + 相邻 26 格。
// 距离 < 25 的点必然落在相邻格内,**不遗漏**(这是跳过远处配对的唯一依据)。
//
// ★ 为何这里不像 WebUI 那样走 Worker:GUI 用 file:// 加载页面(main.js 的
// loadFile),外部 Worker 脚本与 Blob Worker 的可靠性都需要在打包环境
// 里单独验证,不能在“性能修复”里顺带赌上星图能否初始化。网格法本身
// 已把建图从秒级降到百毫秒级,先把它坐实。
var flatLayout = new Float64Array(starmapNodes.length * 3);
for (var li = 0; li < starmapNodes.length; li++) {
var lp = pos[starmapNodes[li].id];
flatLayout[li * 3] = lp.x;
flatLayout[li * 3 + 1] = lp.y;
flatLayout[li * 3 + 2] = lp.z;
}
starmapLayoutRelax(flatLayout, starmapNodes, starmapEdges, maxR);
for (var lj = 0; lj < starmapNodes.length; lj++) {
var lq = pos[starmapNodes[lj].id];
if (!lq) continue;
lq.x = flatLayout[lj * 3];
lq.y = flatLayout[lj * 3 + 1];
lq.z = flatLayout[lj * 3 + 2];
}
// Create nodes
starmapNodes.forEach((n) => {
@ -3314,19 +3543,15 @@ function buildChatStarmapGraph() {
// mesh 已按 rad/baseScale 缩放过(baseScale = rad/0.5),故 glow 用
// 相对比例:世界半径 gr = mesh 缩放 × 本地 scale。
var parentScale = mesh.scale.x || 1;
var gg = starmapSphereGeometry();
var gm = new THREE.MeshBasicMaterial({
color: col,
transparent: true,
opacity: 0.12 + mnr * 0.08,
side: THREE.BackSide,
blending: THREE.AdditiveBlending,
});
var gs = new THREE.Mesh(gg, gm);
// 共享球半径 0.5,故要 ×2 才是单位半径的相对值。
gs.scale.setScalar((gr / parentScale) * 2);
mesh.add(gs);
mesh.userData.glowSphere = gs;
// ★★ 2026-10-13:光晕不再每节点一个子 mesh(与 WebUI 同改)。
//
// GUI 原先 1294 节点 = 1294 球 + 1294 光晕 + 1294 标签 + 966 条边
// ⇒ renderer.info.render.calls 达 **3477/帧**,实测 render() 19–40ms
// 且 1.8–2.5s 的建图里有 433ms 花在「第一次提交时上传 1294 张纹理」。
//
// 光晕是**纯静态装饰**(全仓 grep:glowSphere 只有写入、从无读取,
// 任何动画都不碰它),故合并成单个 InstancedMesh:1294 → 1 draw call。
glowItems.push({ id: n.id, worldRadius: gr, color: col });
// Label sprite
var canvas = document.createElement("canvas");
canvas.width = 256;
@ -3358,26 +3583,36 @@ function buildChatStarmapGraph() {
starmapNodeMeshes.push(mesh);
});
// Create edges
//
// ★ 合并成按颜色分组的 LineSegments(与 WebUI 同改):
// 原来 966 条边 = 966 个独立 Line + 966 个 BufferGeometry,
// 实测隐藏这 966 条就省下 ~8ms/帧(~40% 的 render 成本)。
// 代价:单条边不再能单独点选 —— GUI 此前也没有点选边的交互。
var edgeByColor = {};
starmapEdges.forEach((e) => {
var a = pos[e.source_id],
b = pos[e.target_id];
if (!a || !b) return;
var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466;
var pts = [
new THREE.Vector3(a.x, a.y, a.z),
new THREE.Vector3(b.x, b.y, b.z),
];
var geo = new THREE.BufferGeometry().setFromPoints(pts);
var k = String(col);
if (!edgeByColor[k]) edgeByColor[k] = { col: col, pts: [] };
edgeByColor[k].pts.push(a.x, a.y, a.z, b.x, b.y, b.z);
});
Object.keys(edgeByColor).forEach((k) => {
var g = edgeByColor[k];
if (!g.pts.length) return;
var geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(g.pts, 3));
var mat = new THREE.LineBasicMaterial({
color: col,
color: g.col,
transparent: true,
opacity: 0.4,
});
var line = new THREE.Line(geo, mat);
line.userData = { edgeId: e.id, edgeData: e };
starmapScene.add(line);
starmapEdgeLines.push(line);
var seg = new THREE.LineSegments(geo, mat);
starmapScene.add(seg);
starmapEdgeLines.push(seg);
});
starmapBuildGlowInstances(glowItems, pos);
}
// 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。
@ -4722,6 +4957,15 @@ function resetStarmapCamera() {
}
var starmapLastFrame = 0;
// 空闲帧跳过与星空时间驱动的状态(2026-10-13,与 WebUI 同改)。
//
// ★ 为何需要:GUI 每帧要提交 1809 draw call(1294 标签精灵 + 节点球),
// 图形静止时按 12fps 重绘是纯浪费。配合“星空按 dt 推进”,空闲时可降频
// 而不让星空跳变。
var starmapLastRenderKey = null;
var starmapLastRenderAt = 0;
var starmapLastFrameTs = 0;
var SM_IDLE_RENDER_MS = 5000;
var SM_PULSE_MS = 1400; // 单个脉冲的生命期
var starmapDirty = false; // 全量图过期标记(pulse 检出未知新实体时置位)
function starmapPanelActive() {
@ -4741,13 +4985,65 @@ function starmapAnimate() {
if (now - starmapLastFrame < 83) return;
starmapLastFrame = now;
if (starmapCtrl) starmapCtrl.update();
if (starmapStarField) starmapStarField.rotation.y += 0.0001;
// 星空按**真实流逝时间**旋转(而非每帧固定 0.0001)。
//
// ★ 为何改成时间驱动:以前每帧加固定角度,所以为了“让星空动”就必须
// 按帧重绘;而每次重绘(1809 draw call + 1294 精灵)成本显著。
// 改成按 dt 推进后,星空角度与重绘频率解耦,于是空闲时可以降频。
var dtSec = starmapLastFrameTs ? (now - starmapLastFrameTs) / 1000 : 0;
starmapLastFrameTs = now;
// 0.0001 弧度/帧 @12fps ≈ 0.0012 弧度/秒(等值换算,保持原观感速度)
if (starmapStarField && dtSec > 0 && dtSec < 1) {
starmapStarField.rotation.y += dtSec * 0.0012;
}
// 推进活动脉冲(发光/缩放/呼吸),放在渲染前,
// 这样本帧看到的就是本帧推进后的材质状态。
// hover 冲突由 starmapTickActivity 内部逐节点跳过处理,
// **不**在这里整段跳过 —— 那样会在 hover 期间冻结全部脉冲,
// hover 一放开就看到一堆积压的脉冲同时弹。
starmapTickActivity();
// ★★ 2026-10-13:空闲帧跳过绘制(与 WebUI 同改)。
//
// 为何要做:GUI 的 draw call 已从 3477 降到 1809,但**每帧仍是实成本**
// (1294 个标签精灵 + 节点球)。图形完全静止时按 12fps 重绘是纯浪费。
//
// 判据:相机姿态 + 活动集。任何交互(拖动/悬停/点击)或脉冲都会改变
// camKey ⇒ 立即重绘,不受此间隔限制,手感不受影响。
//
// ★ 必须先判 starmapCam/starmapCtrl:switchChatPanel 里 renderChatStarmap()
// 是**异步**拉数据建图的(loadChatStarmapData),但它紧接着就同步调了
// starmapAnimate()。那一瞬间相机还没建出来(starmapCam === null),
// 直接读 .position 会抛 TypeError —— 实测报在 switchChatPanel 调用栈上。
if (!starmapRen || !starmapCam || !starmapCtrl) return;
// ★ 活跃集概念两仓不同,不能相抄:GUI 用 state.starmapGrown(nodeId → 截止时间戳),
// WebUI 用 starmapMorphSet。此前直接从 WebUI 抄了 starmapMorphSet,
// 而 GUI 没有这个变量 ⇒ 每帧 ReferenceError、星图 0 帧。
var grownCount = 0;
for (var _gk in state.starmapGrown) grownCount++;
var camKey =
starmapCam.position.x.toFixed(3) +
"," +
starmapCam.position.y.toFixed(3) +
"," +
starmapCam.position.z.toFixed(3) +
"," +
starmapCtrl.target.x.toFixed(3) +
"," +
starmapCtrl.target.y.toFixed(3) +
"," +
starmapCtrl.target.z.toFixed(3) +
"," +
state.starmapPulses.length +
"," +
grownCount +
"," +
(starmapHovered ? starmapHovered.userData.nodeId : "") +
"," +
(starmapSelected ? starmapSelected.userData.nodeId : "");
var changed = camKey !== starmapLastRenderKey;
if (!changed && now - starmapLastRenderAt < SM_IDLE_RENDER_MS) return;
starmapLastRenderKey = camKey;
starmapLastRenderAt = now;
if (starmapRen && starmapScene && starmapCam)
starmapRen.render(starmapScene, starmapCam);
}
@ -6656,6 +6952,22 @@ async function connectFetchSSE(url) {
// Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放,
// 通知前端增量补拉历史——避免空等后续聚合事件导致「消息同步不及时」。
syncChatFromHistory();
} else if (type === "system") {
// 内核级状态事件(目前唯一发布者是 plugin_crashed)。
//
// ★ 为何必须补上(2026-10-13 协议审计):该事件一直在发
// (internal/plugin/dynamic_proc.go:175),而 GUI 的事件分发里
// 没有任何分支接它 —— 插件崩溃对桌面端用户完全不可见。
//
// toast(m, isError, warn):GUI 是三参数形态,warn 走黄色样式。
if (p.event === "plugin_crashed") {
var nm = p.plugin || "?";
toast(
__("插件 " + nm + " 异常退出,内核正在重启它", "Plugin " + nm + " crashed; kernel is restarting it"),
false,
true,
);
}
}
} catch (err) {}
}