feat(gui): 星图接上 /memory/graph/pulse + 前端依赖本地化 + 端点对齐门禁

GUI 与服务端 WebUI 插件共享同一套 REST 接口,但两边独立演进、没有任何
强制手段。这次核实发现三处真实漂移(服务端 42 路由 / GUI 只用 29)。

1) 星图接上服务端专为它造的轻量活动端点
   服务端 handleMemoryGraphPulse 的注释写了动机:生产实例全量图谱
   408KB / 1151 节点,为了「知道哪些节点是新的」而每 N 秒拉全量是把
   带宽和 JSON.parse 全花在重复数据上;pulse 只回 id+name+type+
   mention_count+updated_at,几百字节 ~ 几 KB,差两个数量级。
   WebUI dashboard 接了它,GUI 此前只在初始化拉一次全量且完全不轮询
   ⇒ 星图停在打开那一刻的快照,agent 后来学的东西它永远看不到。
   现接入:/runtime 3s + /memory/graph/pulse 10s,两路轮询幂等、
   切连接时停;新实体标「生长」;SSE 的 tool_call/stage/agent_output
   也会触发脉冲(用工具名与回复前 60 字当 hint,让点亮落到本轮相关实体)。
   pulse 检出「全量图里没有的新实体」时置脏,由下一次 renderChatStarmap
   惰性重拉全量 —— 不在脉冲回调里直接拉,否则会把轻量活动源变成每 10s
   拉一次 408KB 全量,正好是 pulse 端点要消除的浪费。

2) 前端依赖本地化,顺带修掉一处安全边界
   四个库从 internal/plugins/webui/static/ 复制到 renderer/vendor/
   (字节一致,由门禁钉住),index.html 不再引用任何公网 CDN。
   与服务端同一理由(见 webui/starmap_vendor_test.go 的注释):
   HomeAgent 支持离线/内网部署,换 CDN 只是把同一个赌注重下一遍。
   ★ renderMd() 的净化器缺失分支不再回退到手写正则(剥 <script>/on*=/
   javascript:)—— 那不是完备的 HTML sanitizer,漏 <iframe srcdoc>、
   SVG 内联事件、data: URI 等,而它渲染的是模型输出与记忆文本,
   都算不可信输入。现在直接退纯文本:库已本地化,走不到该分支。

3) 新增 endpoint-align.test.mjs(进 npm test / CI)
   扫描 app.js 的 api("...") 与 handler.go 的 mux.HandleFunc 对账,
   7 条判据。与既有判据同一路子:读真实源码,不抄逻辑重写。
   反向差集只报告不门禁(管理面端点接不接是产品决策),
   只硬钉「服务端已明确为其设计」的 pulse —— 即本判据的由来。
   已做变异测试:改错 pulse 路径、改回 CDN 均能判红。
   protocol-align.test.mjs 是真浏览器判据(需 Electron+Xvfb+真后端),
   CI 明确不跑,所以这类漂移此前无人发现。

顺带:connectSSE 由隐式全局赋值改为 function 声明(加 "use strict"
会在文件靠后处 ReferenceError,报错点离调用点很远)。

判据:cmd/gui 下 npm test 全绿(4 个 .mjs / 25 条)。
This commit is contained in:
JianFeeeee
2026-10-01 18:23:20 +08:00
parent a39e316e45
commit 53d9af446c
9 changed files with 1685 additions and 40 deletions

View File

@ -140,9 +140,13 @@ jobs:
# ── Electron GUI(纯 Node 测试,零依赖)──
#
# `npm test` 只跑三个 .mjs,全部只 import node: 内置模块(fs/url/path/vm),
# `npm test` 跑四个 .mjs,全部只 import node: 内置模块(fs/url/path/vm),
# 所以**不需要 npm ci、不需要 electron**,秒级完成。
# `npm run test-live` 需真 Electron + 真后端 ⇒ 不进 CI。
#
# endpoint-align.test.mjs 跨出 cmd/gui 读 ../../internal/plugins/webui/handler.go
# 做端点对账 —— checkout 深度足够(默认 fetch-depth:1 已含工作树文件),
# 不需要额外 checkout 步骤。
gui:
name: GUI (node)
runs-on: ubuntu-latest

View File

@ -0,0 +1,232 @@
// GUI ↔ WebUI 端点对齐门禁。
//
// ## 要判的是什么
//
// GUI(cmd/gui/renderer/app.js)与服务端 WebUI 插件(internal/plugins/webui)
// 是两个独立演进的客户端,它们共享同一套 REST 接口。这个共享关系**没有任何
// 强制手段**:服务端加端点不必通知 GUI,GUI 改端点也不必通知服务端。
//
// 真实漂移实例(2026-10-01 核实):服务端 handler.go 里有 39 个路径,
// GUI 只用 28 个。差集里最刺眼的是 `/memory/graph/pulse` ——
// 服务端**专门为星图「跟随 agent 动」**造这个端点,handler 注释写了动机
// (全量图谱生产实例 408KB / 1151 节点,pulse 只有几 KB,差两个数量级),
// WebUI dashboard 接了它(dashboard.js 里 5 处),GUI 却只在初始化时拉一次
// 全量且完全不轮询 ⇒ GUI 星图停在打开那一刻的快照。
//
// 之前没人发现,是因为 protocol-align.test.mjs 是**真浏览器**判据
// (要 Electron + Xvfb + 真后端),CI 明确不跑。
//
// ## 为什么这个判据必须是「文本扫描」而不是运行时探测
//
// 两个已知端点集合(GUI 的 api("...") 与服务端 mux.HandleFunc)都是**源码文本**
// 里的东西,扫源码就能拿到完整集合,不需要起服务、不需要鉴权。
// 而运行时探测(真连一个后端)在这两个维度上不可用:
// - 要真后端 + 有效凭据(本机实测 admin/admin 已失效);
// - 只能看到「已部署版本」的端点,看不到源码里的 —— 而漂移恰恰
// 发生在「源码已加、客户端还没接」的窗口里,那正是要抓的。
//
// 与既有判据(sse-backoff / retry-guard)同一路子:从**真实源码**提取,
// 不抄一份逻辑重写(抄的那份会和真实代码漂移,而漂移正是本判据要防的)。
//
// 运行:node endpoint-align.test.mjs
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, "..", "..");
const APP = join(here, "renderer", "app.js");
const HANDLER_GO = join(repoRoot, "internal", "plugins", "webui", "handler.go");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
// ── 1) 读源码 ───────────────────────────────────────────────────────
if (!existsSync(HANDLER_GO)) {
console.error(`找不到服务端路由表:${HANDLER_GO}`);
process.exit(1);
}
const appSrc = readFileSync(APP, "utf8");
const handlerSrc = readFileSync(HANDLER_GO, "utf8");
// ── 2) 抽端点集合 ───────────────────────────────────────────────────
// 服务端:mux.HandleFunc("...", ...) 里的第一个参数。
// 只取 /api/v1 与 /v1 前缀的(/files/ /uploads/ /static/ / 是页面与静态资源,
// 不是 GUI 的 api() 目标;带上它们只会制造噪音)。
function serverRoutes() {
const out = new Set();
const re = /mux\.HandleFunc\(\s*"([^"]+)"/g;
let m;
while ((m = re.exec(handlerSrc)) !== null) {
const p = m[1];
if (p.startsWith("/api/v1/") || p.startsWith("/v1/")) out.add(p);
}
return out;
}
// GUI:api("...") 的第一个参数。带 query 串的按 '?' 截断
// (如 "/chat/history?limit=" → "/chat/history")。
//
// ★ 正则必须允许字符串后面跟表达式(+ 拼接):星图 pulse 写成
// api("/memory/graph/pulse?since=" + since),只匹配到右引号为止会漏掉它 ——
// 而本判据的由来恰恰就是这个端点,用一个会漏掉它的正则去防它漏接是自相矛盾。
// (?<![\w.]) 前视是为了避开 cliMap() 里的字符串比较(path === "/status"),
// 那不是 api() 调用。
function guiCalls() {
const out = new Set();
const re = /(?<![\w.])api\(\s*"([^"]*)"/g;
let m;
while ((m = re.exec(appSrc)) !== null) {
out.add(m[1].split("?")[0]);
}
return out;
}
const routes = serverRoutes();
const calls = guiCalls();
check(
"能抽到服务端路由",
routes.size >= 30,
`只抽到 ${routes.size} 条 —— 正则可能已与 handler.go 漂移`,
);
check(
"能抽到 GUI 调用",
calls.size >= 20,
`只抽到 ${calls.size} 条 —— 正则可能已与 app.js 漂移`,
);
// ── 3) GUI 调用的每个端点都必须在服务端存在 ──────────────────────────
//
// 这是**硬门禁**:GUI 调了一个服务端没有的端点 ⇒ 运行时必然 404,
// 属于真缺陷。反向(服务端有、GUI 没接)是能力差距,按需评估。
const missing = [...calls].filter((p) => {
if (routes.has(p)) return false;
// 前缀通配:服务端有 "/api/v1/adapters/" 而 GUI 调 "/adapters"
// → api() 会拼成 /api/v1/adapters,靠 Go 1.22 ServeMux 最长前缀匹配命中。
const withPrefix = "/api/v1" + p;
if (routes.has(withPrefix)) return false;
for (const r of routes) {
if (r.endsWith("/") && withPrefix.startsWith(r)) return false;
}
return true;
});
check(
"GUI 调用的端点服务端都存在",
missing.length === 0,
missing.length
? "服务端无此路由(运行时必然 404):\n - " + missing.join("\n - ")
: "",
);
// ── 4) 关键能力端点必须被 GUI 用上 ──────────────────────────────────
//
// 只判「服务端有 + GUI 必须有」的一小撮**能力面**端点,不是全部差集。
// 理由:差集里的管理面端点(/login /logout /tracker/ /knowledge/tree/
// /memory/text ...)该不该接取决于产品决策,逐条人工判;把这几类硬编码
// 进判据会让人为了让门禁变绿而随手接端点。
//
// 这里只钉**服务端已明确为其设计、且 GUI 已经在用同类能力**的端点:
// 星图活动(pulse)是本判据的由来——服务端专门造它、WebUI 用了、GUI 没接。
// 若将来 GUI 确实不再需要星图,请连同下面这条判据一起删,并在注释里
// 写明理由;不要静默留着一条红的门禁。
const mustUse = [
{
path: "/api/v1/memory/graph/pulse",
why: "星图活动源:服务端为「不重复拉 408KB 全量」专门造的轻量端点",
},
];
const unused = mustUse.filter((m) => {
// mustUse 写的是服务端全路径(/api/v1/…),GUI 侧 api() 传的是
// 去掉 /api/v1 前缀的短路径(api() 内部会拼)。两边都比一次。
const short = m.path.replace(/^\/api\/v1/, "");
return !calls.has(m.path) && !calls.has(short);
}).map((m) => m.why);
check(
"能力面端点未被 GUI 漏接",
unused.length === 0,
unused.length ? unused.join(";") : "",
);
// ── 5) 页面不得再引用公网 CDN ───────────────────────────────────────
//
// 与服务端 starmap_vendor_test.go 同一判据(那里钉 WebUI,这里钉 GUI)。
// HomeAgent 支持离线/内网部署,而前端有 4 个硬依赖在公网上时,
// 断网/出口受限环境下星图必坏、且用户无从修复。
const htmlSrc = readFileSync(join(here, "renderer", "index.html"), "utf8");
const cdnRe = /(?:src|href)\s*=\s*["'](https?:\/\/[^"']+)["']/g;
const cdnUrls = [...htmlSrc.matchAll(cdnRe)].map((m) => m[1]);
check(
"index.html 不引用外部 CDN",
cdnUrls.length === 0,
cdnUrls.length ? "仍引用:" + cdnUrls.join(", ") : "",
);
// 本地 vendor 文件必须真的存在(embed/打包漏文件 ⇒ 静默失效)。
const vendorFiles = [
"three.min.js",
"OrbitControls.js",
"marked.min.js",
"purify.min.js",
];
const missingVendor = vendorFiles.filter(
(f) => !existsSync(join(here, "renderer", "vendor", f)),
);
check(
"vendor 第三方库文件都在",
missingVendor.length === 0,
missingVendor.length ? "缺少:" + missingVendor.join(", ") : "",
);
// 两端 vendor 版本必须一致(否则星图/markdown 行为会在两端分叉)。
const serverStatic = join(repoRoot, "internal", "plugins", "webui", "static");
const drift = vendorFiles.filter((f) => {
const a = join(here, "renderer", "vendor", f);
const b = join(serverStatic, f);
if (!existsSync(b)) return true;
return readFileSync(a, "utf8") !== readFileSync(b, "utf8");
});
check(
"GUI 与 WebUI 的 vendor 版本一致",
drift.length === 0,
drift.length
? "内容与 internal/plugins/webui/static 不一致:" + drift.join(", ")
: "",
);
// ── 汇总 ────────────────────────────────────────────────────────────
console.log("");
console.log(` 服务端路由 ${routes.size} 条 / GUI 调用 ${calls.size} 条`);
if (missing.length === 0) {
console.log(" 服务端有、GUI 未使用的端点(差集,供人工评估,非门禁):");
const notUsed = [...routes].filter((r) => {
const short = r.replace(/^\/api\/v1\//, "/").replace(/^\/v1\//, "/");
return !calls.has(r) && !calls.has(short);
});
notUsed.slice(0, 14).forEach((p) => console.log(` · ${p}`));
console.log(` (共 ${notUsed.length} 条未使用)`);
}
console.log("");
if (failures > 0) {
console.log(`全部失败:${failures} 条`);
process.exit(1);
}
console.log("全部通过");

View File

@ -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",
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs",
"test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs"
},
"dependencies": {

View File

@ -55,6 +55,14 @@ const state = {
displays: [],
selfDeviceId: "",
selfGateway: "",
// ---- 星图活动数据源(对齐服务端 /memory/graph/pulse + /runtime)----
starmapPulses: [], // { mesh, until, kind } 活动脉冲队列
starmapGrown: {}, // nodeId -> 生长动画截止时间戳(ms)
starmapLastPulseAt: 0, // 最后一次活动时间(ms),驱动全局呼吸
starmapPulseSince: 0, // 下次 pulse 的回看起点(unix 秒)
starmapPulseTimer: null, // /runtime 3s 轮询 id
starmapActivityTimer: null, // /memory/graph/pulse 10s 轮询 id
_smPrevSched: null, // 上一拍 /runtime 调度器快照(做差值判定)
};
// ===== I18n =====
@ -331,26 +339,31 @@ function toggleAppearance() {
// ===== Utility =====
// 安全渲染 markdown:marked 转 HTML 后由 DOMPurify 剥离脚本/事件/危险标签。
// CDN 加载失败时降级为纯转义文本,绝不把未消毒 HTML 直接写入 innerHTML。
//
// ★ 净化器不可用时**不降级**,直接退到纯文本。
//
// 旧写法是在净化器缺失时掉到手写正则(剥 <script>/on*=/javascript:)再返回。
// 那不是完备的 HTML sanitizer:它漏掉的东西包括 <iframe srcdoc>、
// SVG 内联事件、data: URI、CSS url()/expression 等一整类。
// 而 renderMd 渲染的是**模型输出与记忆文本**——两者都是不可信输入。
// 让不可信输入绕过净化,比不显示 markdown 危险得多。
//
// 库已本地化(renderer/vendor/purify.min.js),正常走不到这个分支;
// 万一打包漏了文件,看到纯文本 + 控制台一条 error,远好过静默开一个 XSS 口子。
function renderMd(text) {
if (typeof text !== "string") text = String(text || "");
var html;
if (typeof marked !== "undefined") {
try { html = marked.parse(text); }
catch (e) { html = escHtml(text); }
catch (e) { html = "<pre>" + escHtml(text) + "</pre>"; }
} else {
html = "<pre>" + escHtml(text) + "</pre>";
}
if (typeof DOMPurify !== "undefined" && typeof DOMPurify.sanitize === "function") {
try { return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); }
catch (e) {}
if (typeof DOMPurify === "undefined" || typeof DOMPurify.sanitize !== "function") {
return "<pre>" + escHtml(text) + "</pre>";
}
// 兜底:手动删除 <script> 块 + 危险属性/事件句柄(CDN 加载失败时)
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/\son\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\son\w+\s*=\s*'[^']*'/gi, "")
.replace(/javascript:/gi, "");
try { return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); }
catch (e) { return "<pre>" + escHtml(text) + "</pre>"; }
}
function escHtml(s) {
@ -2320,8 +2333,8 @@ function renderChatStarmap() {
cont.innerHTML =
'<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:13px">' +
__(
"3D 星图不可用(CDN 加载失败)",
"Star map unavailable (CDN load failed)",
"3D 星图不可用(本地 three.js 缺失)",
"Star map unavailable (local three.js missing)",
) +
"</p>";
state.starmapInit = true;
@ -2336,6 +2349,9 @@ function renderChatStarmap() {
return;
}
if (cont.querySelector("canvas")) {
// 已有 canvas:只调尺寸。但若上轮 pulse 置了脏标志,
// 必须在这里消费(否则新实体永远进不来)。
if (starmapDirty) starmapRefreshFull();
var rect = cont.getBoundingClientRect();
if (starmapRen && rect.width > 0)
starmapRen.setSize(rect.width, Math.max(rect.height, 250));
@ -2360,6 +2376,26 @@ function renderChatStarmap() {
loadChatStarmapData();
}
// starmapDirty 置位后需要重拉全量图,但 renderChatStarmap() 在已有 canvas
// 时会早退(只做 setSize),标志会一直没人消费。
//
// 所以这里直接驱动重建:拉新数据 → 就地重画(initChatStarmap 在 starmapRen
// 已存在时只重建 graph,不重建 renderer/camera/控制器)。
// 轻忘“清标志”而不是“清 canvas”:initChatStarmap 开头对已存在的
// starmapRen 是早退的,所以光清 canvas 不会触发重拉。
async function starmapRefreshFull() {
if (state.starmapLoading) return;
state.starmapLoading = true;
starmapDirty = false;
await loadChatStarmapData();
if (starmapRen) {
buildChatStarmapGraph();
try {
starmapRen.render(starmapScene, starmapCam);
} catch (e) {}
}
}
async function loadChatStarmapData() {
try {
var resp = await api("/memory/graph");
@ -2394,6 +2430,256 @@ async function loadChatStarmapData() {
}
}
// starmapStartActivity 启动两路活动轮询(幂等)。
//
// 两个 setInterval 的 id **必须分开记**:写进同一个字段会被后者覆盖,
// 于是 stopStarmapActivity 只能清掉一个,留下一个永远跑的僵尸定时器。
// (这是 WebUI dashboard.js 里踩过并注释下来的坑,这里跟着分开。)
function starmapStartActivity() {
if (state.starmapPulseTimer) return;
starmapPullActivity();
starmapPullPulse();
state.starmapPulseTimer = setInterval(starmapPullActivity, 3000);
state.starmapActivityTimer = setInterval(starmapPullPulse, 10000);
}
// starmapStopActivity 停掉两路轮询(切连接 / 星图重建时用)。
function starmapStopActivity() {
if (state.starmapPulseTimer) {
clearInterval(state.starmapPulseTimer);
state.starmapPulseTimer = null;
}
if (state.starmapActivityTimer) {
clearInterval(state.starmapActivityTimer);
state.starmapActivityTimer = null;
}
}
// starmapPullActivity 拉 /runtime 快照,把调度器状态映射成图的整体节奏。
//
// ★ 关键:**只读已有的 state.runtime,不自己再发一轮请求**。
// refreshDataOnly() 已经每 15s 取过 /runtime;这里如果再拉,就是把
// 「秒级刷新的活动源」变成「凭空多出来的高频轮询」。
// /runtime 的设计意图正是这条注释(handler.go):让前端秒级刷新而不必
// 反复拉 30KB 的 /kernel。
function starmapPullActivity() {
if (!starmapScene) return;
var s = state.runtime;
if (!s || !s.scheduler) return;
var prev = state._smPrevSched;
if (prev) {
var sum = function (a) {
return (a || []).reduce(function (x, y) {
return x + y;
}, 0);
};
// 队列变深 ⇒ 图整体「绷紧」;中断/抢占上升 ⇒ 强脉冲。
// 判定用差值而不是绝对值:绝对值在长会话里会一直触发,脉冲退化成常亮。
if ((s.ready_queue_depth || 0) > (prev.ready_queue_depth || 0)) {
starmapPulse("tool", null);
}
if (
sum(s.interrupts_by_level) - sum(prev.interrupts_by_level) > 0 ||
sum(s.preempts_by_level) - sum(prev.preempts_by_level) > 0
) {
starmapPulse("output", null);
}
}
state._smPrevSched = s.scheduler;
}
// starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。
function starmapPullPulse() {
if (!starmapScene) return;
var since = state.starmapPulseSince || 0;
api("/memory/graph/pulse?since=" + since)
.then(function (r) {
if (!r || !r.success || !r.data) return;
var now = Math.floor(Date.now() / 1000);
// 30s 重叠窗口:宁可重复点亮几个,也不漏掉刚好卡在边界上的节点。
state.starmapPulseSince = now - 30;
var nodes = r.data.nodes || [];
if (!nodes.length) return;
var known = 0;
nodes.forEach(function (n) {
var m = starmapNodeMeshes.find(function (x) {
return x.userData.nodeId === n.id;
});
// 全量图里没有(可能刚创建)⇒ 本拍忽略,等下次全量重拉。
if (!m) return;
known++;
state.starmapGrown[m.userData.nodeId] = Date.now() + SM_PULSE_MS * 2;
starmapPulse("grow", n.name);
});
// 有新实体却一个都没匹配上 ⇒ 全量图已经过期,标脏让下一拍重拉。
// 阈值取 2:单双节点抖动(同一实体反复 mention)不该触发全量重拉。
//
// ★ 惰性重拉:**不能**在脉冲回调里直接重拉。pulse 是 10s 一次的,
// 而 refreshDataOnly / SSE 也会调 renderChatStarmap;若在这里直接拉,
// 就把「几 KB 的轻量活动源」变成「每 10s 拉一次 408KB 全量」——
// 正好是 pulse 端点存在的理由。所以只置标志,由下一次
// renderChatStarmap 消费。
if (known === 0 && nodes.length > 2) starmapDirty = true;
})
.catch(function () {});
}
// starmapPulse 发出一次活动脉冲。kind: "tool" | "stage" | "output" | "grow"
function starmapPulse(kind, hint) {
if (!starmapScene || !starmapNodeMeshes.length) return;
var now = Date.now();
state.starmapLastPulseAt = now;
var targets = starmapPickPulseTargets(hint);
if (!targets.length) return;
var life = kind === "grow" ? SM_PULSE_MS * 1.6 : SM_PULSE_MS;
targets.forEach(function (m) {
// 去重:同一个节点已在队列里就只延长到期时间,不再 push 新项。
// 否则「stage 事件 + tool_call 事件」会点亮两次同一个节点,
// 队列里出现重复项,脉冲结束时要复位两次。
var existing = null;
for (var i = 0; i < state.starmapPulses.length; i++) {
if (state.starmapPulses[i].mesh === m) {
existing = state.starmapPulses[i];
break;
}
}
if (existing) {
existing.until = now + life;
existing.kind = kind;
} else {
state.starmapPulses.push({ mesh: m, until: now + life, kind: kind });
}
});
// 队列上限:密集工具调用时脉冲无限堆积会占内存。
if (state.starmapPulses.length > 260)
state.starmapPulses = state.starmapPulses.slice(-260);
}
// starmapPickPulseTargets 选出该被点亮的节点。
//
// ★ 匹配必须用「词」而不是子串包含:hint="knowledge" 与实体名 "k"/"e"
// 互为子串,会让半个图谱(含「时」「会」这类单字实体)全部命中 ⇒
// 脉冲退化成「全图齐亮」,既看不出关联,又把队列瞬间打满。
// (WebUI 实测:旧写法一次 pulse 选中 250 个节点、队列顶到 260 上限。)
function starmapPickPulseTargets(hint) {
var out = [];
if (!starmapNodeMeshes.length) return out;
if (hint) {
var keys = starmapHintTokens(hint);
if (keys.length) {
for (var i = 0; i < starmapNodeMeshes.length && out.length < 26; i++) {
var nd = starmapNodeMeshes[i].userData.nodeData || {};
var nm = String(nd.name || "").toLowerCase();
var ty = String(nd.type || "").toLowerCase();
if (!nm) continue;
for (var k = 0; k < keys.length; k++) {
var key = keys[k];
// 词边界命中:恰好等于该词,或以该词为词首(knowledge_base 命中 knowledge)。
if (nm === key || nm.indexOf(key + "_") === 0 || ty === key) {
out.push(starmapNodeMeshes[i]);
break;
}
}
}
}
}
// 不足时按 mention_count 补齐:高权重节点本身就是最常被 agent 触碰的,
// 用它们代表「整体活动」合理。
if (out.length < 8) {
var cand = starmapNodeMeshes
.filter(function (m) {
return out.indexOf(m) === -1;
})
.sort(function (a, b) {
return (
(b.userData.nodeData.mention_count || 0) -
(a.userData.nodeData.mention_count || 0)
);
});
for (var c = 0; c < cand.length && out.length < 8; c++) out.push(cand[c]);
}
return out;
}
// starmapHintTokens 把提示切成「词」(≥3 字符),供上面做词边界匹配。
function starmapHintTokens(hint) {
var raw = String(hint)
.toLowerCase()
.split(/[^a-z0-9\u4e00-\u9fa5]+/);
var out = [];
for (var i = 0; i < raw.length; i++) {
var t = raw[i];
if (t.length >= 3 && out.indexOf(t) === -1) out.push(t);
}
return out;
}
// starmapTickActivity 在渲染循环里推进所有脉冲与余晖。
//
// 挂在已有的 starmapAnimate(~12fps)里,不额外起定时器 ——
// 12fps 对「发光冲高 + 缩放」这类动画足够,多起一个 rAF 只会和它抢帧。
function starmapTickActivity() {
var now = Date.now();
// 1) 活动脉冲:发光冲高 + 尺寸微扩
if (state.starmapPulses.length) {
var keep = [];
for (var i = 0; i < state.starmapPulses.length; i++) {
var p = state.starmapPulses[i];
if (p.until <= now) {
p.mesh.material.emissiveIntensity = p.mesh.userData.baseEmissive;
p.mesh.scale.setScalar(p.mesh.userData.baseScale || 1);
continue;
}
keep.push(p);
var left = (p.until - now) / SM_PULSE_MS; // 1→0
var k = 1 - left; // 0→1
var wave = Math.sin(Math.min(1, k) * Math.PI);
var amp = p.kind === "output" ? 1.8 : 1.2;
p.mesh.material.emissiveIntensity = p.mesh.userData.baseEmissive + wave * amp;
// hover 期间不覆写 scale(会打断 hover 的 1.2 倍高亮)
if (p.mesh !== starmapHovered)
p.mesh.scale.setScalar(p.mesh.userData.baseScale * (1 + wave * 0.28));
// 生长:新节点从 0 弹到正常大小
if (p.kind === "grow") {
var g = Math.min(1, k * 1.4);
p.mesh.scale.setScalar(p.mesh.userData.baseScale * (0.15 + 0.85 * g));
}
}
state.starmapPulses = keep;
}
// 2) 「生长」余晖:脉冲结束后短暂保留一点亮
if (state.starmapGrown) {
for (var gid in state.starmapGrown) {
if (state.starmapGrown[gid] <= now) {
delete state.starmapGrown[gid];
continue;
}
var gm = starmapNodeMeshes.find(function (x) {
return String(x.userData.nodeId) === gid;
});
if (gm)
gm.material.emissiveIntensity = Math.max(
gm.material.emissiveIntensity,
gm.userData.baseEmissive + 0.6,
);
}
}
// 3) 全局呼吸:距上次活动越近越亮,实现「agent 一忙图就活」。
// 只抽样一部分节点,避免每帧改 1151 个材质。
var idle = (now - (state.starmapLastPulseAt || 0)) / 4000;
var breathe = Math.max(0, 1 - idle);
if (breathe > 0.01 && starmapNodeMeshes.length) {
var stride = 24;
for (var b = 0; b < starmapNodeMeshes.length; b += stride) {
var m2 = starmapNodeMeshes[b];
if (m2.userData.baseEmissive === undefined) continue;
if (m2 === starmapHovered) continue;
m2.material.emissiveIntensity =
m2.userData.baseEmissive + breathe * 0.25;
}
}
}
function getStarmapBg() {
return 0x0a0a1a;
}
@ -2437,6 +2723,20 @@ function initChatStarmap() {
starmapRen.domElement.addEventListener("mousemove", onStarmapMove);
starmapRen.domElement.addEventListener("click", onStarmapClick);
window.addEventListener("resize", onStarmapResize);
// 活动数据源:/runtime 3s + /memory/graph/pulse 10s。
//
// 为什么要接:服务端**专门**为星图造了 /memory/graph/pulse 这个轻量活动
// 端点(handler_memory.go 的注释写了动机:生产实例全量图谱 408KB /
// 1151 节点 / 866 边,为了「知道哪些节点是新的」而每 N 秒拉一次全量,
// 是把带宽和 JSON.parse 全花在重复数据上;pulse 只回 id+name+type+
// mention_count+updated_at,几百字节 ~ 几 KB,差两个数量级)。
//
// 而 GUI 此前**只在初始化时拉一次**全量 /memory/graph 且完全没有轮询
// ⇒ 星图停在打开那一刻的快照,agent 后面学的东西它永远看不到。
// WebUI dashboard 接了这个端点,GUI 没接 —— 这是两端的一次真实漂移。
//
// 幂等:重复 initChatStarmap(切连接/重建)不会起第二轮定时器。
starmapStartActivity();
if (starmapRaf) cancelAnimationFrame(starmapRaf);
// 首次加载强制渲染一帧(即使星图面板未激活,切换过去也有内容)
try {
@ -2599,6 +2899,9 @@ function buildChatStarmapGraph() {
mesh.userData.nodeData = n;
mesh.userData.nodeId = n.id;
mesh.userData.baseEmissive = ei;
// baseScale 必须记:脉冲结束时要把 scale 复原到「按 mention_count
// 缩放后」的值,而不是 set(1,1,1)—— 那会把大节点缩成最小尺寸。
mesh.userData.baseScale = rad / 0.5;
// Glow sphere
var gr = rad * 1.2 + mnr * 0.5;
var gg = new THREE.SphereGeometry(gr, 16, 12);
@ -3892,9 +4195,13 @@ function onStarmapMove(e) {
if (hits.length > 0) {
var n = hits[0].object;
if (starmapHovered !== n) {
if (starmapHovered) starmapHovered.scale.set(1, 1, 1);
// 复位用 baseScale,不能用 set(1,1,1):节点是按 mention_count
// 缩放过的(userData.baseScale),置 1 会把大节点缩成最小尺寸;
// 同时脉冲也靠 baseScale 复原,两边必须用同一个基准。
if (starmapHovered)
starmapHovered.scale.setScalar(starmapHovered.userData.baseScale || 1);
starmapHovered = n;
n.scale.set(1.2, 1.2, 1.2);
n.scale.setScalar(n.userData.baseScale * 1.2);
var nd = n.userData.nodeData;
if (infoEl) {
var e1 = document.getElementById("sm-info-name");
@ -3913,7 +4220,7 @@ function onStarmapMove(e) {
}
} else {
if (starmapHovered) {
starmapHovered.scale.set(1, 1, 1);
starmapHovered.scale.setScalar(starmapHovered.userData.baseScale || 1);
starmapHovered = null;
}
if (!starmapSelected && infoEl) infoEl.style.display = "none";
@ -4003,6 +4310,8 @@ function resetStarmapCamera() {
}
var starmapLastFrame = 0;
var SM_PULSE_MS = 1400; // 单个脉冲的生命期
var starmapDirty = false; // 全量图过期标记(pulse 检出未知新实体时置位)
function starmapPanelActive() {
// 仅当 chat 视图且"星图"子面板激活时才运行动画
if (!state.currentView || state.currentView !== "chat") return false;
@ -4021,6 +4330,12 @@ function starmapAnimate() {
starmapLastFrame = now;
if (starmapCtrl) starmapCtrl.update();
if (starmapStarField) starmapStarField.rotation.y += 0.0001;
// 推进活动脉冲(发光/缩放/呼吸),放在渲染前,
// 这样本帧看到的就是本帧推进后的材质状态。
// hover 冲突由 starmapTickActivity 内部逐节点跳过处理,
// **不**在这里整段跳过 —— 那样会在 hover 期间冻结全部脉冲,
// hover 一放开就看到一堆积压的脉冲同时弹。
starmapTickActivity();
if (starmapRen && starmapScene && starmapCam)
starmapRen.render(starmapScene, starmapCam);
}
@ -5549,6 +5864,13 @@ async function saveConnForm() {
state.eventSource.close();
state.eventSource = null;
}
// 切连接时停掉星图轮询:旧后端的图谱/调度器数据对上新后端无意义,
// 而定时器闭包里的 api() 会一直打旧地址。
starmapStopActivity();
starmapDirty = false;
state.starmapPulses = [];
state.starmapGrown = {};
state._smPrevSched = null;
state.messages = [];
updateConnIndicator();
connectSSE();
@ -5573,8 +5895,15 @@ document.addEventListener("keydown", (e) => {
cancelConnForm();
});
// ===== SSE (override for fetch-based) =====
connectSSE = () => {
// ===== SSE (fetch-based) =====
// 用 fetch + ReadableStream 而不是 EventSource:需要自定义请求头
// (X-API-Key / Last-Event-ID),EventSource 不支持。
//
// ★ 必须用 function 声明而不是 `connectSSE = () => {}`:
// 后者是隐式全局赋值,依赖非严格模式。一旦给 app.js 加 "use strict"
// (或改成 ES module),就在这一行 ReferenceError,而它位于文件靠后
// 位置 —— 报错点离调用点很远,难查。
function connectSSE() {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
@ -5676,6 +6005,10 @@ async function connectFetchSSE(url) {
last.content = p.content || "";
last._final = true;
rerenderChatIfActive();
// 星图:回复产出也是一次活动(agent 说完了 → 图亮一下)。
// 用文本前若干字符当 hint,让点亮落到**本轮相关**的实体上,
// 而不是每次都亮同一批高权重节点。
starmapPulse("output", String(p.content || "").slice(0, 60));
endChatTurn();
return;
}
@ -5808,6 +6141,9 @@ async function connectFetchSSE(url) {
status: p.status || "ok",
plugin: p.plugin || "",
});
// 星图:工具名就是最好的 hint(调 knowledge_* 就亮知识节点),
// 这是「图在跟 agent 动」最直接的体现。
starmapPulse("tool", p.tool || "");
var pidx = (state.pendingTools || []).indexOf(p.tool);
if (pidx !== -1) state.pendingTools.splice(pidx, 1);
state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || "");
@ -5855,6 +6191,10 @@ async function connectFetchSSE(url) {
);
if (tool) state.toolFlash = true;
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
// 星图:阶段事件与 tool_call 事件可能都到(两者由不同路径发布),
// 所以 starmapPulse 自身按 (mesh,until) 粗粒度去重:同节点
// 刷新到期时间而非叠加队列项。
if (tool) starmapPulse("tool", tool);
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
if (!state.pendingTools) state.pendingTools = [];
state.pendingTools.push(tool);

View File

@ -6,26 +6,41 @@
<title>HomeAgent</title>
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="stylesheet" href="style.css" />
<script
src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"
onerror="console.warn('marked CDN failed')"
></script>
<script
src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"
onerror="console.warn('DOMPurify CDN failed')"
></script>
<!--
第三方库全部本地化(renderer/vendor/),不引用任何公网 CDN。
为什么(与服务端 webui 插件同一条理由,见
internal/plugins/webui/starmap_vendor_test.go 的注释):
HomeAgent 明确支持离线/内网部署,而前端原本有 4 个硬依赖在公网上
(cdnjs/jsdelivr)。真实现象是「服务端 curl 同一批 URL 返回 200,
但用户点星图看到『3D 星图不可用(CDN 加载失败)』」——不是服务端的问题,
是客户端访问不到公共 CDN(内网/出口受限/断网)。
换 CDN 只是把同一个赌注重下一遍。
★ 版本与 internal/plugins/webui/static/ 下那四个文件一致(同样是
three r128 / marked 4.3.0 / DOMPurify 3.2.4),两端保持一致,
由 vendor-align.test.mjs 钉住。
★ DOMPurify 从 CDN 挪到本地还修掉了一个**安全边界**:
它加载失败时 renderMd() 会回退到手写正则剥 <script>/on*=/javascript:,
那不是完备的 HTML sanitizer,而它渲染的是**模型输出与记忆文本**
(都算不可信输入)。净化器必须可用,不能有降级路径。
-->
<script src="vendor/three.min.js"></script>
<script src="vendor/OrbitControls.js"></script>
<script src="vendor/marked.min.js"></script>
<script src="vendor/purify.min.js"></script>
<script>
setTimeout(function () {
if (!window.THREE) window._THREE_FAILED = true;
}, 8000);
// 本地依赖缺失时的可见报错:宁可显式说「本地库缺失」,
// 也不要静默退化(静默退化的症状是星图空白/markdown 变纯文本,
// 用户看不出是打包漏了文件)。
if (!window.THREE) window._THREE_FAILED = true;
if (typeof marked === "undefined")
console.error("[vendor] marked.min.js 缺失:Markdown 将退化为纯文本");
if (typeof DOMPurify === "undefined")
console.error(
"[vendor] purify.min.js 缺失:Markdown 净化不可用(不应发生,库已本地化)",
);
</script>
</head>
<body>

1045
cmd/gui/renderer/vendor/OrbitControls.js vendored Normal file

File diff suppressed because it is too large Load Diff

6
cmd/gui/renderer/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
cmd/gui/renderer/vendor/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long