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