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

@ -1,4 +1,4 @@
.PHONY: test-gui all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full .PHONY: test-gui all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions check-starmap-shared check-client-protocol csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full
# HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与 # HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与
# deploy/packaging/build.sh 保持一致)。 # deploy/packaging/build.sh 保持一致)。
# #
@ -368,6 +368,8 @@ test:
$(MAKE) csrc-test || rc=$$?; \ $(MAKE) csrc-test || rc=$$?; \
$(MAKE) check-csrc || rc=$$?; \ $(MAKE) check-csrc || rc=$$?; \
$(MAKE) check-codec-cgo-only || rc=$$?; \ $(MAKE) check-codec-cgo-only || rc=$$?; \
$(MAKE) check-starmap-shared || rc=$$?; \
$(MAKE) check-client-protocol || rc=$$?; \
exit $$rc exit $$rc
# check-codec-cgo-only:钉死「编解码层完全 C 化」这一决定。 # check-codec-cgo-only:钉死「编解码层完全 C 化」这一决定。
@ -415,6 +417,29 @@ sync-client-versions:
check-client-versions: check-client-versions:
@bash deploy/scripts/sync-client-versions.sh --check @bash deploy/scripts/sync-client-versions.sh --check
# check-starmap-shared:钉住 WebUI 与 GUI 的星图布局**同源**。
#
# 为何需要:两份客户端内联脚本里的布局代码曾是手抄副本,已经因此出过两次事故:
# ① WebUI 修好 O(n²)→网格法,GUI 那份没跟着改(换客户端卡顿原样存在)
# ② 抄空闲帧跳过时抄了 WebUI 独有的 starmapMorphSet,GUI 实际用
# state.starmapGrown ⇒ 渲染循环每帧 ReferenceError、星图 0 帧
# 现在唯一事实源在 shared/starmap/,由 tools/starmap-sync.mjs 写进两处标记块。
# 本目标校验「两处标记块 == 共享源」;手改任一副本会立刻红。
sync-starmap:
@node tools/starmap-sync.mjs
check-starmap-shared:
@node tools/starmap-sync.mjs --check
# check-client-protocol:客户端↔核心的协议一致性门禁。
#
# 为何单列一个显式目标(虽然这些用例在 go test ./... 里也会跑):
# 它们是**跨客户端契约**判据,改协议时最该被单独、反复地跑。2026-10-13 的
# 协议审计查出四处静默不一致(幽灵类型 agent_error、system 发了没人收、
# CLI 缺 stage、设备桥静默丢弃 push),全部靠这类判据才能防复发。
check-client-protocol:
@$(GO) test -count=1 -run 'TestSSESubTypesAreRealEventTypes|TestEveryPublishedEventHasAnOutwardPath|TestClientsRecognizeServerEventTypes|TestNoPhantomFrameTypes|TestStageFrameFieldsMatchServer|TestDeviceBridgeOpCoverage|TestSseWhitelistParsesToExpectedSet' ./internal/plugins/webui/
# lint-full:在 vet 之外跑 golangci-lint(阈值见 .golangci.yml,起步 warn-only)。 # lint-full:在 vet 之外跑 golangci-lint(阈值见 .golangci.yml,起步 warn-only)。
# 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。 # 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。
.PHONY: lint-full .PHONY: lint-full

View File

@ -8,7 +8,7 @@
"scripts": { "scripts": {
"start": "electron . --no-sandbox", "start": "electron . --no-sandbox",
"dev": "electron . --no-sandbox --dev", "dev": "electron . --no-sandbox --dev",
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs && node package-config.test.mjs && node loading-notify.test.mjs && node computeruse.test.mjs", "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs && node package-config.test.mjs && node loading-notify.test.mjs && node computeruse.test.mjs && node starmap-perf.test.mjs",
"test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs", "test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs",
"test-package": "../../scripts/gui-package-smoke.sh" "test-package": "../../scripts/gui-package-smoke.sh"
}, },

View File

@ -2692,6 +2692,21 @@ function renderChatStarmap() {
return; return;
} }
if (state.starmapLoading) 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; state.starmapLoading = true;
loadChatStarmapData(); loadChatStarmapData();
} }
@ -3065,35 +3080,269 @@ function initChatStarmap() {
} catch (e) {} } catch (e) {}
starmapAnimate(); 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)`——因为半径 // n=3754 时约 15 轮。旧实现固定 50 轮,且每轮是 O(n²)。
// gr 各不相同,看起来「只能各自 new」。但 three.js 里几何体的大小应当由 // 下限 14 是观感值:网格法下每轮很便宜(n·k 而非 n²),轮数多一些布局更舒展;
// **mesh.scale** 表达,几何体本身应当共享。 // 再少会让叶节点分布明显不均(母节点周围挤堆)。
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 个节点): // 入参/出参见调用方:flat 是扁平 Float64Array(长度 3n),
// // nodes 提供 id 与 mention_count(权重),edges 提供 source_id/target_id。
// Mesh/SphereGeometry 2588 个 geometry 数据合计 22.67MB // 用扁平数组而非对象数组,是为了让可行时能直接走结构化克隆交给 Worker。
// Sprite/BufferGeometry 1294 个(label 纹理,每个 256×64 RGBA ≈ 64KB) function starmapLayoutRelax(flat, nodes, edges, maxR) {
// var n = nodes.length;
// 两者都不释放:buildChatStarmapGraph 只starmapScene.remove(m), if (n === 0) return flat;
// **从不 dispose()** —— remove 只把对象从场景树摘下来,WebGL 侧资源 var CUTOFF = 25; // == 斥力半径,见文件头说明
// (buffer/texture)仍被引用,不释放。星图每次全量重建泄漏一整轮, var BASE_R = 15;
// 于是进程常驻内存反复冲上GB 级、靠 GC 回收后再度暴涨,界面随之卡住。 // 归一化权重(斥力/吸力都按它加权)
// var mn = new Float64Array(n);
// 修法两条:单位球共享(几何体从 2588 份降到 1 份)+ 重建时 dispose。 var maxMc = 1,
// 单位球细节:SphereGeometry(1, 16, 12) 建一次,实例按 gr 缩放, minMc = 0;
// 因为 mesh 是父节点,缩放会叠加到子 glow 上—— 所以 glow 挂到 mesh 下时 for (var a = 0; a < n; a++) {
// 要用 gr/rad 的相对比例。 var m = (nodes[a] && nodes[a].mention_count) || 0;
// ★ 单位球几何体的**半径取0.5**,不是 1。 if (m > maxMc) maxMc = m;
// if (a === 0 || m < minMc) minMc = m;
// 原因:节点缩放基准是 mesh.userData.baseScale = rad / 0.5 —— }
// 分母0.5 就是原SphereGeometry 的半径。若把共享球改成半径 1, var rng = maxMc - minMc || 1;
// baseScale 会整体缩放一倍(大的节点变得更大、小的更小),且与 for (var b = 0; b < n; b++) {
// 脉冲结束时的复原逻辑(setScalar(baseScale))不自洽。 mn[b] = (((nodes[b] && nodes[b].mention_count) || 0) - minMc) / rng;
// 保持 0.5 ⇒ baseScale 公式原样成立,行为与修复前逐像素一致。 }
// 边下标(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; 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() { function starmapSphereGeometry() {
if (!starmapUnitSphere) starmapUnitSphere = new THREE.SphereGeometry(0.5, 16, 12); if (!starmapUnitSphere) starmapUnitSphere = new THREE.SphereGeometry(0.5, 16, 12);
return starmapUnitSphere; return starmapUnitSphere;
@ -3161,6 +3410,14 @@ function buildChatStarmapGraph() {
}); });
starmapNodeMeshes = []; starmapNodeMeshes = [];
starmapEdgeLines = []; starmapEdgeLines = [];
// ★ 光晕实例要一并释放(同一次重建里它会被重建)。
if (starmapGlowMesh) {
starmapScene.remove(starmapGlowMesh);
if (starmapGlowMesh.material) starmapGlowMesh.material.dispose();
// geometry 是共享单位球(starmapSphereGeometry),不能 dispose。
starmapGlowMesh = null;
}
var glowItems = [];
if (starmapNodes.length === 0) return; if (starmapNodes.length === 0) return;
// Calculate node degrees for leaf node detection // Calculate node degrees for leaf node detection
var nodeDegs = {}; var nodeDegs = {};
@ -3228,61 +3485,33 @@ function buildChatStarmapGraph() {
}; };
}); });
// Force-directed simulation // Force-directed simulation
for (var it = 0; it < 50; it++) { //
var ids = Object.keys(pos); // ★★ 2026-10-13:O(n²) → O(n·k) 网格法(与 WebUI dashboard.js 同一算法)。
// Repulsion //
for (var i = 0; i < ids.length; i++) { // 旧实现在此内联跑 50 轮 `for i / for j=i+1` 全对全斥力。实测(Electron
for (var j = i + 1; j < ids.length; j++) { // + 真生产库 3754 块):**单次建图阻塞主线程 1.8–9 秒**。而斥力体里本来
var a = pos[ids[i]], // 就有 `if (d < 25)` —— 只对近邻施力。既然只关心近邻,全对全就是白算:
b = pos[ids[j]]; // 把空间切成边长 == 斥力半径的格子,每点只比自身格 + 相邻 26 格。
var dx = a.x - b.x, // 距离 < 25 的点必然落在相邻格内,**不遗漏**(这是跳过远处配对的唯一依据)。
dy = a.y - b.y, //
dz = a.z - b.z, // ★ 为何这里不像 WebUI 那样走 Worker:GUI 用 file:// 加载页面(main.js 的
d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; // loadFile),外部 Worker 脚本与 Blob Worker 的可靠性都需要在打包环境
var rf = 0.5 + (a.mn + b.mn) * 0.5; // 里单独验证,不能在“性能修复”里顺带赌上星图能否初始化。网格法本身
if (d < 25) { // 已把建图从秒级降到百毫秒级,先把它坐实。
var force = (0.06 * rf) / Math.max(d, 0.5); var flatLayout = new Float64Array(starmapNodes.length * 3);
a.x += (dx / d) * force; for (var li = 0; li < starmapNodes.length; li++) {
a.y += (dy / d) * force; var lp = pos[starmapNodes[li].id];
a.z += (dz / d) * force; flatLayout[li * 3] = lp.x;
b.x -= (dx / d) * force; flatLayout[li * 3 + 1] = lp.y;
b.y -= (dy / d) * force; flatLayout[li * 3 + 2] = lp.z;
b.z -= (dz / d) * force; }
} starmapLayoutRelax(flatLayout, starmapNodes, starmapEdges, maxR);
} for (var lj = 0; lj < starmapNodes.length; lj++) {
} var lq = pos[starmapNodes[lj].id];
// Attraction along edges if (!lq) continue;
starmapEdges.forEach((e) => { lq.x = flatLayout[lj * 3];
var a = pos[e.source_id], lq.y = flatLayout[lj * 3 + 1];
b = pos[e.target_id]; lq.z = flatLayout[lj * 3 + 2];
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;
}
});
} }
// Create nodes // Create nodes
starmapNodes.forEach((n) => { starmapNodes.forEach((n) => {
@ -3314,19 +3543,15 @@ function buildChatStarmapGraph() {
// mesh 已按 rad/baseScale 缩放过(baseScale = rad/0.5),故 glow 用 // mesh 已按 rad/baseScale 缩放过(baseScale = rad/0.5),故 glow 用
// 相对比例:世界半径 gr = mesh 缩放 × 本地 scale。 // 相对比例:世界半径 gr = mesh 缩放 × 本地 scale。
var parentScale = mesh.scale.x || 1; var parentScale = mesh.scale.x || 1;
var gg = starmapSphereGeometry(); // ★★ 2026-10-13:光晕不再每节点一个子 mesh(与 WebUI 同改)。
var gm = new THREE.MeshBasicMaterial({ //
color: col, // GUI 原先 1294 节点 = 1294 球 + 1294 光晕 + 1294 标签 + 966 条边
transparent: true, // ⇒ renderer.info.render.calls 达 **3477/帧**,实测 render() 19–40ms
opacity: 0.12 + mnr * 0.08, // 且 1.8–2.5s 的建图里有 433ms 花在「第一次提交时上传 1294 张纹理」。
side: THREE.BackSide, //
blending: THREE.AdditiveBlending, // 光晕是**纯静态装饰**(全仓 grep:glowSphere 只有写入、从无读取,
}); // 任何动画都不碰它),故合并成单个 InstancedMesh:1294 → 1 draw call。
var gs = new THREE.Mesh(gg, gm); glowItems.push({ id: n.id, worldRadius: gr, color: col });
// 共享球半径 0.5,故要 ×2 才是单位半径的相对值。
gs.scale.setScalar((gr / parentScale) * 2);
mesh.add(gs);
mesh.userData.glowSphere = gs;
// Label sprite // Label sprite
var canvas = document.createElement("canvas"); var canvas = document.createElement("canvas");
canvas.width = 256; canvas.width = 256;
@ -3358,26 +3583,36 @@ function buildChatStarmapGraph() {
starmapNodeMeshes.push(mesh); starmapNodeMeshes.push(mesh);
}); });
// Create edges // Create edges
//
// ★ 合并成按颜色分组的 LineSegments(与 WebUI 同改):
// 原来 966 条边 = 966 个独立 Line + 966 个 BufferGeometry,
// 实测隐藏这 966 条就省下 ~8ms/帧(~40% 的 render 成本)。
// 代价:单条边不再能单独点选 —— GUI 此前也没有点选边的交互。
var edgeByColor = {};
starmapEdges.forEach((e) => { starmapEdges.forEach((e) => {
var a = pos[e.source_id], var a = pos[e.source_id],
b = pos[e.target_id]; b = pos[e.target_id];
if (!a || !b) return; if (!a || !b) return;
var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466; var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466;
var pts = [ var k = String(col);
new THREE.Vector3(a.x, a.y, a.z), if (!edgeByColor[k]) edgeByColor[k] = { col: col, pts: [] };
new THREE.Vector3(b.x, b.y, b.z), edgeByColor[k].pts.push(a.x, a.y, a.z, b.x, b.y, b.z);
]; });
var geo = new THREE.BufferGeometry().setFromPoints(pts); 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({ var mat = new THREE.LineBasicMaterial({
color: col, color: g.col,
transparent: true, transparent: true,
opacity: 0.4, opacity: 0.4,
}); });
var line = new THREE.Line(geo, mat); var seg = new THREE.LineSegments(geo, mat);
line.userData = { edgeId: e.id, edgeData: e }; starmapScene.add(seg);
starmapScene.add(line); starmapEdgeLines.push(seg);
starmapEdgeLines.push(line);
}); });
starmapBuildGlowInstances(glowItems, pos);
} }
// 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。 // 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。
@ -4722,6 +4957,15 @@ function resetStarmapCamera() {
} }
var starmapLastFrame = 0; 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 SM_PULSE_MS = 1400; // 单个脉冲的生命期
var starmapDirty = false; // 全量图过期标记(pulse 检出未知新实体时置位) var starmapDirty = false; // 全量图过期标记(pulse 检出未知新实体时置位)
function starmapPanelActive() { function starmapPanelActive() {
@ -4741,13 +4985,65 @@ function starmapAnimate() {
if (now - starmapLastFrame < 83) return; if (now - starmapLastFrame < 83) return;
starmapLastFrame = now; starmapLastFrame = now;
if (starmapCtrl) starmapCtrl.update(); 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 冲突由 starmapTickActivity 内部逐节点跳过处理,
// **不**在这里整段跳过 —— 那样会在 hover 期间冻结全部脉冲, // **不**在这里整段跳过 —— 那样会在 hover 期间冻结全部脉冲,
// hover 一放开就看到一堆积压的脉冲同时弹。 // hover 一放开就看到一堆积压的脉冲同时弹。
starmapTickActivity(); 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) if (starmapRen && starmapScene && starmapCam)
starmapRen.render(starmapScene, starmapCam); starmapRen.render(starmapScene, starmapCam);
} }
@ -6656,6 +6952,22 @@ async function connectFetchSSE(url) {
// Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放, // Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放,
// 通知前端增量补拉历史——避免空等后续聚合事件导致「消息同步不及时」。 // 通知前端增量补拉历史——避免空等后续聚合事件导致「消息同步不及时」。
syncChatFromHistory(); 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) {} } catch (err) {}
} }

View File

@ -0,0 +1,225 @@
// 星图性能判据 —— 钉住 2026-10-13 的两轮优化。
//
// ## 为什么需要这一组(真实现象,不是理论)
//
// `cmd/gui/renderer/app.js` 与 `internal/plugins/webui/dashboard.js` 的星图
// 代码是**两份手抄的副本**(当年 diff 为 0)。WebUI 先修了性能,GUI 因为
// 是独立文件**完全没跟着变** ⇒ 用户换 GUI 客户端时同样的卡顿原样存在。
//
// 更糟的是「照抄会抄错」:本轮把 WebUI 的空闲帧跳过抄进 GUI 时,直接抄了
// `starmapMorphSet` —— 那是 **WebUI 独有的变量**,GUI 用的是
// `state.starmapGrown`。结果是渲染循环**每帧 ReferenceError、星图 0 帧**
// (实测 frame 恒为 1)。这类错误在语法检查里看不出来(JS 不校验未声明
// 标识符),必须靠运行或静态判据抓。
//
// ## 判据取「结构性事实」而不是性能数字
//
// 跑真 Electron 量毫秒数需要后端 + 生产图数据,不适合做单测;而这几处
// 缺陷都有确定的结构特征(全对全双层循环 / 每节点一个光晕 mesh /
// 每边一个 Line / 无空闲跳过),源码静态可判且回归立刻红。
//
// 运行:node cmd/gui/starmap-perf.test.mjs
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const APP = join(here, "renderer", "app.js");
const src = readFileSync(APP, "utf8");
// 取一个顶层函数的函数体(到下一个行首 `}` 为止)。
function funcBody(name) {
const marker = "function " + name + "(";
const i = src.indexOf(marker);
if (i < 0) return null;
const rest = src.slice(i);
const end = rest.indexOf("\n}\n");
return end < 0 ? rest : rest.slice(0, end);
}
let failures = 0;
const check = (name, ok, detail) => {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
console.log("星图性能判据(GUI):");
// ── ① 力导向布局必须是网格近邻(O(n·k)),不得再是全对全(O(n²))──
//
// 旧实现 `for (it<50) { for (i) { for (j=i+1) } }`:生产库 3754 块
// = 3.52 亿次配对,Electron 实测单次建图阻塞 1.8–9 秒。
{
const relax = funcBody("starmapLayoutRelax");
check("存在 starmapLayoutRelax(网格近邻实现)", relax !== null);
if (relax) {
check(
"starmapLayoutRelax 走空间网格(grid + CUTOFF)",
/\bgrid\b/.test(relax) && /\bCUTOFF\b/.test(relax),
"缺网格:全对全斥力在生产库实测阻塞数秒",
);
check(
"starmapLayoutRelax 不再有 Object.keys(pos) 全量遍历",
!/Object\.keys/.test(relax),
"Object.keys 全量遍历是 O(n²) 写法的特征",
);
}
check(
"存在迭代数自适应 starmapLayoutIterationsFor",
src.includes("function starmapLayoutIterationsFor("),
"固定 50 轮在大图上绝大多数轮次是在重复收敛后的状态",
);
// 全对全的字符特征:`for (var it = 0; it < 50` 配 `for (var j = i + 1`
check(
"已无「50 轮 × 全对全」双层循环",
!/for \(var it = 0; it < 50; it\+\+\)[\s\S]{0,900}?for \(var j = i \+ 1/.test(src),
"这正是被替换掉的 O(n²) 布局",
);
}
// ── ② 光晕必须合并成 InstancedMesh(每节点一个 mesh = 1294 draw call)──
{
check(
"存在 starmapBuildGlowInstances(光晕合并)",
src.includes("function starmapBuildGlowInstances("),
);
const build = funcBody("buildChatStarmapGraph");
check("buildChatStarmapGraph 调用了 starmapBuildGlowInstances", !!build && build.includes("starmapBuildGlowInstances("));
check(
"光晕不再逐个 add 到 mesh 上",
!/mesh\.add\(gs\)/.test(src),
"每节点一个光晕子 mesh 会让 draw call 翻倍",
);
check(
"光晕 InstancedMesh 用的是共享单位球",
/new THREE\.InstancedMesh\(/.test(src) && /starmapSphereGeometry\(\)/.test(src),
"实例化必须复用共享几何,否则几何体数量没省下来",
);
}
// ── ③ 边必须按颜色合并成 LineSegments(每边一个 Line = 966 draw call)──
{
const build = funcBody("buildChatStarmapGraph");
check(
"边走 LineSegments 合并",
!!build && build.includes("new THREE.LineSegments("),
"每边一个 THREE.Line 是 966 次 draw call;合并后为关系类型数",
);
check(
"边不再逐条 new THREE.Line(",
!/new THREE\.Line\(geo, mat\)/.test(src),
"逐条 Line 是被替换掉的写法",
);
}
// ── ④ 空闲帧跳过 + 星空时间驱动 ──
{
const anim = funcBody("starmapAnimate");
check("starmapAnimate 存在", anim !== null);
if (anim) {
check(
"空闲帧跳过(SM_IDLE_RENDER_MS)",
anim.includes("SM_IDLE_RENDER_MS"),
"图形静止时按 12fps 重绘 1800+ draw call 是纯浪费",
);
check(
"camKey 变化时立即重绘(交互不受节流影响)",
/camKey !== starmapLastRenderKey/.test(anim),
"若无论如何都受间隔限制,拖动会变粘",
);
check(
"星空按真实 dt 推进(而非每帧固定角度)",
/dtSec/.test(anim) && !/rotation\.y \+= 0\.0001;/.test(anim),
"每帧固定角度 ⇒ 必须按帧重绘,无法降频",
);
// ★★ 相机守卫:switchChatPanel 里 renderChatStarmap() 是异步建图,
// 但紧接着就同步调 starmapAnimate(),那一瞬间 starmapCam 仍是 null。
check(
"camKey 之前有 starmapCam/starmapCtrl 守卫",
/if \(!starmapRen \|\| !starmapCam \|\| !starmapCtrl\) return;/.test(anim),
"无守卫时读 starmapCam.position 会抛 TypeError(实测报在 switchChatPanel 栈上)",
);
}
// ★★ 跨仓抄错的那一处:GUI 没有 starmapMorphSet。
check(
"不引用 WebUI 独有的 starmapMorphSet",
!/starmapMorphSet\s*[?)]/.test(src),
"GUI 用 state.starmapGrown;抄 WebUI 的 starmapMorphSet 会让每帧 ReferenceError(星图 0 帧,实测)",
);
check(
"空闲跳过用的是 GUI 自己的活跃集 state.starmapGrown",
/grownCount/.test(src) || /state\.starmapGrown/.test(src),
);
check("SM_IDLE_RENDER_MS 已定义", /var SM_IDLE_RENDER_MS\s*=/.test(src));
}
// ── ⑤ 布局代码必须与 WebUI 同源(shared/starmap/layout.js)──
//
// 为何单独钉这一条:这份布局曾在 GUI 与 WebUI 各存一份手抄,两次真实事故
// 都出自这个分叉(WebUI 换网格法 GUI 没跟上;反向抄时抄了 WebUI 独有的
// starmapMorphSet ⇒ GUI 每帧 ReferenceError、星图 0 帧)。
// 现在唯一事实源在 shared/starmap/layout.js,由 tools/starmap-sync.mjs
// 写进本文件的标记块。这里校验「本文件里的块 == 共享源」。
{
const BEGIN = "// ===== BEGIN shared:starmap/layout =====";
const END = "// ===== END shared:starmap/layout =====";
const b = src.indexOf(BEGIN);
const e = src.indexOf(END);
check("app.js 含共享块标记", b >= 0 && e > b);
if (b >= 0 && e > b) {
const block = src.slice(b, e + END.length);
const shared = readFileSync(
join(here, "..", "..", "shared", "starmap", "layout.js"),
"utf8",
).replace(/\r\n/g, "\n");
check(
"共享块与 shared/starmap/layout.js 逐字节一致(无漂移)",
block.includes(shared.trim()),
"修法:node tools/starmap-sync.mjs(改算法请只改共享源)",
);
check(
"共享块内带「由生成器产出」提示",
/生成/.test(block) || /generated/.test(block),
"避免后人当普通代码手改",
);
}
}
// ── ⑥ 首屏不得为隐藏的星图面板建图 ──
//
// GUI 默认视图是 chat,而 renderAll 在 chat 视图会调 renderChatStarmap()。
// 无守卫时每次启动都为「看不见的」星图面板付 943ms 建图 + 433ms 纹理上传。
{
const rcs = funcBody("renderChatStarmap");
check("renderChatStarmap 存在", rcs !== null);
if (rcs) {
// ★★ 必须先剔除注释再判!
// 本判据第一版写的是 `body.includes("offsetParent")`,而**注释里
// 恰好写了这个词**(解释为何要加守卫),于是把真正的守卫代码删掉、
// 只留注释,判据仍然通过 —— 变异测试当场拆穿过这个假绿。
// 教训:凡「某标识符是否出现在函数体」类判据,都要先去注释,
// 否则注释会变成判据的挡箭牌。
const code = rcs
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
check(
"renderChatStarmap 有可见性守卫(代码中真读 offsetParent,不只写在注释里)",
/\.offsetParent/.test(code),
"否则首屏(chat 视图)就会为隐藏的星图面板建图,白付 1.4 秒",
);
check(
"可见性守卫在 loadChatStarmapData 之前(真能拦住)",
code.indexOf(".offsetParent") < code.indexOf("loadChatStarmapData()"),
"守卫写在拉数据之后等于没拦",
);
}
}
console.log(
failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`,
);
process.exit(failures === 0 ? 0 : 1);

View File

@ -81,6 +81,17 @@ func TestStarmapNoPerFrameFullScan(t *testing.T) {
"固定 50 轮在大图上绝大多数轮次是在重复收敛后的状态") "固定 50 轮在大图上绝大多数轮次是在重复收敛后的状态")
} }
// ★★ 布局代码必须**与 GUI 同源**(2026-10-13)。
//
// 为何单独钉这一条:这份布局曾在 dashboard.js 与 cmd/gui/renderer/app.js
// 各存一份手抄。两次真实事故都出自这个分叉:
// ① WebUI 换上网格法后 GUI 那份没跟着改(换客户端卡顿原样存在)
// ② 反向抄时抄了 WebUI 独有的 starmapMorphSet,GUI 用 state.starmapGrown
// ⇒ 渲染循环每帧 ReferenceError、星图 0 帧
// 现在唯一事实源在 shared/starmap/layout.js,由 tools/starmap-sync.mjs
// 写进两处标记块。这里校验「本文件里的块 == 共享源」,手改会被立刻拦下。
checkStarmapSharedBlock(t, src)
// Worker 脚本必须存在且用同一套网格算法 // Worker 脚本必须存在且用同一套网格算法
wb, err := os.ReadFile(filepath.Join("static", "starmap-layout-worker.js")) wb, err := os.ReadFile(filepath.Join("static", "starmap-layout-worker.js"))
if err != nil { if err != nil {
@ -127,3 +138,38 @@ func readDashboardJS(t *testing.T) string {
} }
return string(b) return string(b)
} }
// checkStarmapSharedBlock 校验本文件里的共享块与 shared/starmap/layout.js 逐字节一致。
//
// 判据读宿主文件 + 共享源 + 生成器三者约定:标记块内容 = 生成器产出的那一整段。
// 用一个薄封装调生成器(--check)比在 Go 里重算拼接规则更可靠 ——
// 拼接规则只有一处(tools/starmap-sync.mjs),重复实现它正是「两份副本」的老毛病。
func checkStarmapSharedBlock(t *testing.T, src string) {
t.Helper()
const begin = "// ===== BEGIN shared:starmap/layout ====="
const end = "// ===== END shared:starmap/layout ====="
b := strings.Index(src, begin)
e := strings.Index(src, end)
if b < 0 || e < 0 || e < b {
t.Fatalf("找不到共享块标记(%s / %s)—— 星图布局必须由 shared/starmap/layout.js 生成",
begin, end)
}
block := src[b : e+len(end)]
// 共享源必须被完整包进块里(抽首尾两段特征串判,避免把整份源码抄进断言)。
shared, err := os.ReadFile(filepath.Join("..", "..", "..", "shared", "starmap", "layout.js"))
if err != nil {
t.Fatalf("读共享源失败: %v", err)
}
sh := strings.TrimRight(strings.ReplaceAll(string(shared), "\r\n", "\n"), "\n")
// 共享源的正文(去掉首尾空行)必须逐字出现在块内。
body := strings.TrimSpace(sh)
if !strings.Contains(block, body) {
t.Error("本文件的星图布局块与 shared/starmap/layout.js 不一致(漂移)。\n" +
" 修法:node tools/starmap-sync.mjs\n" +
" 若是改算法,请只改 shared/starmap/layout.js,再跑同步 ——\n" +
" 直接改这个副本会让 WebUI 与 GUI 再次分叉(已因此出过两次事故)。")
}
if !strings.Contains(block, "generated") && !strings.Contains(block, "生成") {
t.Error("共享块内应带「本块由生成器产出」的提示,避免后人当普通代码手改")
}
}

69
shared/starmap/README.md Normal file
View File

@ -0,0 +1,69 @@
# 星图布局共享源(WebUI / GUI 共用)
## 一句话
这里只共享**布局数学**——3 个不碰 DOM、不碰全局状态的纯函数。
**不是**在合并 WebUI 与 GUI 的星图实现,两者本就不该合并。
## 共享范围(就这么大)
| 文件 | 内容 |
|---|---|
| `layout.js` | `starmapLayoutRelax`(力导向松弛)、`starmapLayoutIterationsFor`(迭代数自适应)、`starmapLayoutMaxMoveThreshold`(收敛早停阈值) |
判据实测:**153 行代码、3 个函数、零 `document`/`window`/`THREE`/`state.*` 引用**。
入参只有 `flat`(扁平坐标数组)、`nodes`、`edges`、`maxR`,出参是同一份坐标。
没有 UI、没有状态、没有渲染——纯数学。
## ★ 明确不共享的部分(别去"补完"它)
WebUI 与 GUI 是**两种不同的客户端**,不是同一份前端的两个皮肤:
| | WebUI | GUI |
|---|---|---|
| 形态 | 浏览器页面,Go `//go:embed` 进二进制 | Electron 桌面应用,`file://` 加载 |
| 视图体系 | 侧栏页签(概览/对话/星图/插件/设置/适配器/内核) | 自有面板 + 托盘 + 设备桥 |
| 星图承载 | 概览页门面 + 聊天面板 + 独立页签(三容器搬运) | 聊天内的星图子面板 |
| 节点标签 | `starmapDrawLabels` 2D 覆盖层 | 每节点 canvas sprite |
| 活跃集概念 | `starmapMorphSet`(mesh 集合) | `state.starmapGrown`(nodeId→时间戳) |
| 独有能力 | SSE `Last-Event-ID` 重放、`smAdaptGraph` 归一化、星图页签 | computer use、`disposeStarmapObject` 显式 GPU 释放、`starmapPanelActive` 面板判据 |
实测:WebUI 星图侧有 **54 个函数**,GUI 只有 **24 个**;两份文件 69 个同名函数里
**逐字节相同的为 0**(缩进风格都不同:GUI 2 空格 / WebUI tab)。
强行统一这些是**跨客户端 UI 重构**,风险远大于收益。
⇒ 只有「布局数学」这一层,两边的**语义本就该完全相同**、且**分叉的代价已被证明高昂**,
才值得抽取。其余部分请各自演化。
## 为什么必须共享这一层
分叉的代价不是理论上的,已经兑现过两次:
1. WebUI 把 O(n²) 全对全布局换成网格近邻(生产库 3754 块:3.52 亿次配对 → n·k 次),
**GUI 那份没跟着改** ⇒ 换客户端卡顿原样存在(实测单次建图阻塞 1.8–9 秒)。
2. 把空闲帧跳过从 WebUI 抄进 GUI 时抄了 `starmapMorphSet`(**WebUI 独有**),
GUI 实际用 `state.starmapGrown` ⇒ 渲染循环**每帧 ReferenceError、星图 0 帧**。
JS 不校验未声明标识符,语法检查看不出来。
## 机制
```
shared/starmap/layout.js ← 唯一事实源(改这里)
│
├─ tools/starmap-sync.mjs ─→ internal/plugins/webui/dashboard.js [BEGIN/END 标记块]
└─────────────────────────→ cmd/gui/renderer/app.js [BEGIN/END 标记块]
```
为什么用「编辑期生成」而不是「运行时加载」:GUI 用 `file://` 且可能离线启动,
让它去服务端拉 `dashboard.js` 会把「星图能否初始化」绑到网络与登录态上;
反过来让 WebUI 内联 GUI 的代码要动 `go:embed` 与启动顺序。
生成器两边风险都避开:**运行时零依赖,两侧各自仍是自包含文件**。
## 用法
node tools/starmap-sync.mjs # 同步
node tools/starmap-sync.mjs --check # 只校验,漂移则退出 1
make check-starmap-shared # 同上(已接入 make test)
★ 改布局算法**只改 `layout.js`**,然后跑 sync。
手改任一副本会被三处门禁同时拦下(生成器 `--check` + WebUI 的 Go 判据 + GUI 的 `.mjs` 判据)。

209
shared/starmap/layout.js Normal file
View File

@ -0,0 +1,209 @@
// 星图力导向布局 —— 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。
// 自适应迭代数:小图跑满 50 轮(值得精细),大图退到 14 轮。
//
// 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 就地松弛坐标。
//
// 入参/出参见调用方: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;
}

94
tools/starmap-sync.mjs Normal file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env node
// 把 shared/starmap/*.js 同步进各客户端的内联脚本标记块。
//
// ## 为什么是「生成器」而不是「运行时加载」
//
// 两个客户端的内联脚本形态不同、加载机制也不同:
// · WebUI:dashboard.js 由 Go 的 //go:embed 编进二进制,再被 init() 拼进 HTML
// · GUI :app.js 由 electron 用 file:// 直接 <script src> 加载
//
// 让 GUI 去服务端拉 dashboard.js 会把「星图能否初始化」绑到网络与登录态上
// (实测 GUI 是 file:// + 可能离线启动),而让 WebUI 内联一份 GUI 的代码则
// 要动 embed 与启动顺序。两者都比「编辑期把同一份源码写进两个文件的标记块」
// 风险大得多。
//
// 所以取生成器方案:**唯一事实源在 shared/,两份客户端文件里的相应区间由本
// 脚本重写**。判据(starmap-perf.test.mjs 一侧)钉住三处逐字节相同,
// 手改任一副本会被立刻拦下。
//
// 用法:
// node tools/starmap-sync.mjs # 同步
// node tools/starmap-sync.mjs --check # 只校验,漂移则退出 1
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
// 共享块 → 宿主文件。块名与标记名构成契约。
const SHARED = "starmap/layout";
const MARK_BEGIN = `// ===== BEGIN shared:${SHARED} =====`;
const MARK_END = `// ===== END shared:${SHARED} =====`;
const HOSTS = [
"internal/plugins/webui/dashboard.js",
"cmd/gui/renderer/app.js",
];
const CHECK = process.argv.includes("--check");
const sharedPath = join(ROOT, "shared", SHARED + ".js");
const shared = readFileSync(sharedPath, "utf8").replace(/\r\n/g, "\n");
// 标记块内容 = 共享源 + 一行来源提示。提示放在块内(而不是块外),
// 这样「块内容逐字节相同」这条判据才能同时保证「两边都带着同一提示」。
const block =
MARK_BEGIN +
"\n" +
shared.replace(/\n+$/, "") +
"\n// 本块由 tools/starmap-sync.mjs 从 shared/" +
SHARED +
".js 生成 —— 请改共享源,不要直接改这里。\n" +
MARK_END;
let drift = 0;
let changed = 0;
for (const rel of HOSTS) {
const p = join(ROOT, rel);
const src = readFileSync(p, "utf8").replace(/\r\n/g, "\n");
const b = src.indexOf(MARK_BEGIN);
const e = src.indexOf(MARK_END);
if (b < 0 || e < 0 || e < b) {
console.error(`✗ ${rel}: 找不到共享标记块(${MARK_BEGIN} / ${MARK_END})`);
drift++;
continue;
}
const cur = src.slice(b, e + MARK_END.length);
if (cur === block) {
console.log(`✓ ${rel}: 已与共享源一致`);
continue;
}
drift++;
if (CHECK) {
console.error(`✗ ${rel}: 共享块与 shared/${SHARED}.js 不一致(漂移)`);
continue;
}
writeFileSync(p, src.slice(0, b) + block + src.slice(e + MARK_END.length), "utf8");
console.log(`~ ${rel}: 已刷新共享块`);
changed++;
}
if (CHECK) {
if (drift) {
console.error(
`\n共享块漂移 ${drift} 处。修法:node tools/starmap-sync.mjs\n` +
`(若是改了算法,请只改 shared/${SHARED}.js,再跑同步。)`,
);
process.exit(1);
}
console.log("\n共享块一致");
process.exit(0);
}
console.log(changed ? `\n已同步 ${changed} 个文件` : "\n无需改动");