mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-14 19:43:37 +00:00
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:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user