mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
27
Makefile
27
Makefile
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
225
cmd/gui/starmap-perf.test.mjs
Normal file
225
cmd/gui/starmap-perf.test.mjs
Normal 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);
|
||||||
@ -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
69
shared/starmap/README.md
Normal 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
209
shared/starmap/layout.js
Normal 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
94
tools/starmap-sync.mjs
Normal 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无需改动");
|
||||||
Reference in New Issue
Block a user