';
html += '
';
html += '' + knowEsc(v.name) + "";
@@ -2976,7 +3043,7 @@
html += "
";
if (v.media && v.media.length) {
html += '
';
- v.media.forEach(function (m) {
+ v.media.forEach((m) => {
var kind = m.kind || "file";
html +=
'
[' + knowEsc(kind) + "] " + knowEsc(m.digest.slice(0, 8)) + "…";
@@ -3004,7 +3071,7 @@
function renderKnowTree(node, depth) {
var html = "";
// 本节点的条目
- (node.items || []).forEach(function (it) {
+ (node.items || []).forEach((it) => {
html +=
'
";
});
// 子分类
- (node.children || []).forEach(function (c) {
+ (node.children || []).forEach((c) => {
var isCur = _knowCat === c.path;
var mark = isCur ? "● " : "";
html +=
@@ -3186,14 +3253,14 @@
context: document.getElementById("chat-panel-context"),
knowledge: document.getElementById("chat-panel-knowledge"),
};
- Object.keys(panels).forEach(function (k) {
+ Object.keys(panels).forEach((k) => {
var p = panels[k];
if (p) p.classList.toggle("active", k === tab);
});
if (el) {
var parent = el.parentElement;
if (parent) {
- Array.from(parent.children).forEach(function (ch) {
+ Array.from(parent.children).forEach((ch) => {
ch.classList.remove("active");
});
el.classList.add("active");
@@ -3249,7 +3316,7 @@
if (!data) return 0;
if (typeof data.last_seq === "number") return data.last_seq;
var mx = 0;
- (data.messages || []).forEach(function (m) {
+ (data.messages || []).forEach((m) => {
if (m && m.seq > mx) mx = m.seq;
});
return mx;
@@ -3270,7 +3337,7 @@
if (prev && prev._grow) sm._grow = true;
return sm;
}
- list.forEach(function (sm) {
+ list.forEach((sm) => {
if (!sm) return;
var seq = sm.seq;
var found = -1;
@@ -3310,7 +3377,7 @@
msgs.push(sm);
changed = true;
});
- list.forEach(function (sm) {
+ list.forEach((sm) => {
if (sm && sm.seq > (state.chatLastSeq || 0)) state.chatLastSeq = sm.seq;
});
if (changed) rerenderChat();
@@ -3327,19 +3394,19 @@
var after = state.chatLastSeq || 0;
if (!after) {
// 还没建立游标(首次 / 本地为空):退回一次性全量,交给已有一致性逻辑
- return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then(function (data) {
+ return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then((data) => {
state.chatLastSeq = historyLastSeq(data);
return mergeChatFromHistory(data);
});
}
return api("/chat/history?after=" + after)
- .then(function (data) {
+ .then((data) => {
if (!data) return;
state.chatLastSeq = historyLastSeq(data) || after;
applyServerMessages(data.messages || [], false);
return api("/chat/history?limit=1");
})
- .then(function (tail) {
+ .then((tail) => {
if (tail && tail.messages && tail.messages.length) {
applyServerMessages(tail.messages.slice(-1), true);
}
@@ -3394,8 +3461,8 @@
if (_syncingChat) return Promise.resolve();
_syncingChat = true;
return pollChatIncremental()
- .catch(function () {})
- .then(function () {
+ .catch(() => {})
+ .then(() => {
_syncingChat = false;
});
}
@@ -3404,7 +3471,6 @@
// 关键约束:**绝不**用更短的服务端页替换更长的本地列表(那会让用户翻上来的旧页
// 凭空消失、视口跳回顶部)。
function mergeChatFromHistory(data) {
- {
if (!data || !data.messages || data.messages.length === 0) return;
var serverMsgs = data.messages;
var localMsgs = state.messages;
@@ -3469,7 +3535,7 @@
if (msgsEl) {
var aiAvatar = '

';
var userAvatar = '
';
- newMsgs.forEach(function (m) {
+ newMsgs.forEach((m) => {
var role = m.role || m.Role || "user";
var c = m.content || m.Content || "";
if (role === "assistant") c = renderMd(c); else c = escHtml(c);
@@ -3491,7 +3557,6 @@
Array.prototype.push.apply(state.messages, newMsgs);
// 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位)
syncStreamingPlaceholder();
- }
}
// syncStreamingPlaceholder:同步聊天占位符的可见性
function syncStreamingPlaceholder() {
@@ -3503,7 +3568,7 @@
if (showPh && !existing) {
var aiAvatar = '

';
var pillHtml = "";
- (state.pendingTools || []).forEach(function (nm) {
+ (state.pendingTools || []).forEach((nm) => {
pillHtml += '
' + escHtml(nm) + '';
});
msgsEl.insertAdjacentHTML("beforeend", '
' + aiAvatar + '
' + (pillHtml ? '' + pillHtml + '' : '') + '
');
@@ -3549,7 +3614,7 @@
return;
}
var html = "";
- list.forEach(function (t, i) {
+ list.forEach((t, i) => {
var detailId = "term-detail-" + i;
var scr = (state.termScreens && state.termScreens[t.id]) || null;
var running = scr ? scr.running : !!t.running;
@@ -3619,9 +3684,7 @@
var r = document.getElementById("cmd-list");
var cnt = document.getElementById("cmd-count-badge");
if (!r) return;
- var running = (state.terminals || []).filter(function (t) {
- return t.running;
- });
+ var running = (state.terminals || []).filter((t) => t.running);
if (cnt) cnt.textContent = running.length;
if (running.length === 0) {
r.innerHTML =
@@ -3638,7 +3701,7 @@
"
" +
__("运行时长", "Uptime") +
" | ";
- running.forEach(function (t) {
+ running.forEach((t) => {
var scr = (state.termScreens && state.termScreens[t.id]) || null;
var out = scr ? scr.output : t.output || "";
html +=
@@ -3685,13 +3748,15 @@
return;
}
console.log("[SSE] connected");
- es.addEventListener("agent_output", function (e) {
+ es.addEventListener("agent_output", (e) => {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
// 光点:agent → 输出通道。总览页拓扑靠它做"消息出去了"的动画;
// 其它页没有该 SVG,rtSparkOutput 查不到元素就是 no-op。
if (p.channel) rtSparkOutput(ev.source || "", p.channel);
+ // 星图跟随:agent 输出是一次完整活动的收尾,给一记强脉冲。
+ starmapPulse("output", p.channel || null);
console.log(
"[SSE] agent_output received",
p.content ? p.content.substring(0, 50) : "(empty)",
@@ -3775,7 +3840,7 @@
});
// token 级流式增量:逐块追加到当前回复内容(流式生成中);
// reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。
- es.addEventListener("content_delta", function (e) {
+ es.addEventListener("content_delta", (e) => {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
@@ -3814,7 +3879,7 @@
rerenderChat();
} catch (ex) {}
});
- es.addEventListener("terminal_output", function (e) {
+ es.addEventListener("terminal_output", (e) => {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
@@ -3837,7 +3902,7 @@
console.error("[SSE] terminal_output error", ex);
}
});
- es.addEventListener("reasoning", function (e) {
+ es.addEventListener("reasoning", (e) => {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
@@ -3865,7 +3930,7 @@
} catch (ex) {}
});
// token 级流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废
- es.addEventListener("reasoning_delta", function (e) {
+ es.addEventListener("reasoning_delta", (e) => {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
@@ -3891,7 +3956,7 @@
rerenderChat();
} catch (ex) {}
});
- es.addEventListener("tool_call", function (e) {
+ es.addEventListener("tool_call", (e) => {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
@@ -3922,13 +3987,16 @@
var pidx = (state.pendingTools || []).indexOf(p.tool);
if (pidx !== -1) state.pendingTools.splice(pidx, 1);
state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || "");
+ // 星图跟随:工具调用是最可靠的活动信号。工具名(如
+ // knowledge_list / qq_send)会拿去匹配相关实体节点并点亮。
+ starmapPulse("tool", p.tool);
rerenderChat();
} catch (ex) {
console.error("[SSE] tool_call error", ex);
}
});
// 注:后端不发布 tool_result 类型事件(工具结果随 EventToolCall 一次发出),无此监听器。
- es.addEventListener("stage", function (e) {
+ es.addEventListener("stage", (e) => {
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
@@ -3954,9 +4022,11 @@
} else if (phase === "after_output") {
rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done"));
}
+ // 星图跟随:阶段推进也作为活动信号(工具名优先匹配节点)。
+ starmapPulse("stage", tool || phase);
state.pipelinePhase = phase;
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
- state.pipelineTimer = setTimeout(function () {
+ state.pipelineTimer = setTimeout(() => {
state.pipelinePhase = "";
if (document.getElementById("rt-sec-pipe")) renderRuntime();
}, 2500);
@@ -3989,7 +4059,7 @@
});
// channel_input:内核在输入进来时另发的一条轻量事件(只带通道名与 agent
// id,不带正文)。总览页拓扑靠它画"光点进入 agent"。
- es.addEventListener("channel_input", function (e) {
+ es.addEventListener("channel_input", (e) => {
try {
var d = JSON.parse(e.data);
var src = d.source || "";
@@ -3998,27 +4068,27 @@
console.error("[SSE] channel_input error", ex);
}
});
- es.onopen = function () {
+ es.onopen = () => {
console.log("[SSE] connection opened");
};
- es.onerror = function (e) {
+ es.onerror = (e) => {
console.error("[SSE] error", e);
// 1) 立即 close 阻止浏览器原生自动重连与手动 setTimeout(connectSSE) 双连接竞态
try { state.eventSource && state.eventSource.close(); state.eventSource = null; } catch(ex){}
// 2) 连接错误期间可能丢失事件,增量补拉历史(无闪烁)
- syncChatFromHistory().catch(function(){});
+ syncChatFromHistory().catch(()=> {});
// 3) 2s 后手动重连(比原 5s 更快恢复)
setTimeout(connectSSE, 2000);
};
// sync_required:Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放,
// 通知前端增量补拉历史——避免前端空等后续聚合事件导致「消息同步不及时」。
- es.addEventListener("sync_required", function(e) {
+ es.addEventListener("sync_required", (e) => {
console.log("[SSE] sync_required received, incremental sync");
- syncChatFromHistory().catch(function(){});
+ syncChatFromHistory().catch(()=> {});
});
// Periodically refresh sidebar data
if (state._sidebarRefresh) clearInterval(state._sidebarRefresh);
- state._sidebarRefresh = setInterval(async function () {
+ state._sidebarRefresh = setInterval(async () => {
try {
var td = await api("/terminals");
if (td && td.terminals) state.terminals = td.terminals;
@@ -4075,7 +4145,7 @@
) +
"
";
html += '
';
- svcs.forEach(function (s) {
+ svcs.forEach((s) => {
var url = s.url || "";
if (url && token) url += "?__token=" + encodeURIComponent(token);
var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)";
@@ -4163,17 +4233,15 @@
__("选择 .hmap 文件上传", "Upload .hmap file") +
"
";
var disabledNames = {};
- (state.disabledPlugins || []).forEach(function (d) {
+ (state.disabledPlugins || []).forEach((d) => {
disabledNames[d.name] = d;
});
- var installedNames = (state.installedPlugins || []).map(function (p) {
- return p.name;
- });
+ var installedNames = (state.installedPlugins || []).map((p) => p.name);
var allPluginNames = {};
- plugins.forEach(function (p) {
+ plugins.forEach((p) => {
allPluginNames[p.name] = true;
});
- state.disabledPlugins.forEach(function (d) {
+ state.disabledPlugins.forEach((d) => {
allPluginNames[d.name] = true;
});
html +=
@@ -4197,12 +4265,10 @@
"
" +
__("操作", "Actions") +
" | ";
- names.forEach(function (name) {
+ names.forEach((name) => {
var isExternal = installedNames.indexOf(name) >= 0;
var isDisabled = disabledNames[name];
- var loaded = plugins.some(function (p) {
- return p.name === name;
- });
+ var loaded = plugins.some((p) => p.name === name);
var statusHtml =
loaded && !isDisabled
? '
' +
@@ -4273,7 +4339,7 @@
"" +
__("操作", "Actions") +
" | ";
- installed.forEach(function (p) {
+ installed.forEach((p) => {
html +=
"
" +
escHtml(p.name) +
@@ -4319,7 +4385,7 @@
tools.length +
")" +
'';
- tools.forEach(function (t) {
+ tools.forEach((t) => {
html +=
' t.name === "healthcheck");
if (!healthTool) {
panel.innerHTML =
'' +
@@ -4608,12 +4672,8 @@
" "
);
var checks = r.checks || [];
- var passed = checks.filter(function (c) {
- return c.pass;
- }).length;
- var failed = checks.filter(function (c) {
- return !c.pass;
- }).length;
+ var passed = checks.filter((c) => c.pass).length;
+ var failed = checks.filter((c) => !c.pass).length;
var html =
'' +
'' +
@@ -4630,7 +4690,7 @@
__("总计: ", "Total: ") +
checks.length +
" ";
- checks.forEach(function (c) {
+ checks.forEach((c) => {
var passClass = c.pass ? "check-pass" : "check-fail";
if (c.status === "skip") passClass = "check-skip";
html +=
@@ -4777,7 +4837,7 @@
")";
if (k.plugins?.length) {
html += '';
- k.plugins.forEach(function (p) {
+ k.plugins.forEach((p) => {
html +=
' ' + escHtml(p.name) + "";
});
@@ -4804,12 +4864,31 @@
starmapSelected = null,
starmapAutoView = true;
var starmapRaf = null;
+ // 共享几何:1151 节点各自 new SphereGeometry 会产生 1151 个
+ // BufferGeometry(另加同样数量的光晕球)。几何形状对所有节点相同,
+ // 差别只在外层 mesh.scale,故共享一份即可。半径固定 0.5,
+ // 真实半径由 scale 给出(见 buildChatStarmapGraph)。
+ var starmapGeo = null,
+ starmapGlowGeo = null;
+ // 标签角标:hover / 选中时显示的 HTML 元素(零显存,文字清晰)。
+ var starmapLabelEl = null;
+ // smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。
+ // 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go),
+ // 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc,
+ // 分类配色实际上从未生效过。
var smTypeColors = {
person: 0x4488ff,
task: 0xff8844,
ai: 0xaa44ff,
concept: 0x44ff88,
object: 0xff4444,
+ // 服务端还会产出这些(indexer_test 里可见 "Person"/"Location")
+ location: 0xffaa44,
+ source: 0x8899ff,
+ document: 0xaabbcc,
+ event: 0xff88cc,
+ entity: 0x44ddcc,
+ scene: 0x88ff44,
};
var smEdgeColors = {
喜欢: 0xff6b6b,
@@ -4820,6 +4899,228 @@
创建: 0xff9ff3,
};
+ // ===== 星图活动:让图跟上 agent 的动作 =====
+ //
+ // 改之前:starmapAnimate() 只转星空,节点完全静止。现在三路信号:
+ //
+ // 1. starmapPulse(kind) —— 实时。SSE 的 tool_call / stage /
+ // agent_output 触发。命中的节点(按工具名匹配已知实体,否则随机
+ // 取一批)做一次扩散涟漪 + 发光冲高。
+ // 2. starmapPullActivity() —— /runtime 调度器快照,3s。
+ // ready_queue_depth > 0 ⇒ 图整体「绷紧」(轻微缩放脉冲);
+ // interrupts/preempts 上升 ⇒ 高优先级别的红色电弧感闪烁。
+ // 3. starmapPullPulse() —— /memory/graph/pulse,10s。
+ // 最近变动的实体(新增概念 / mention_count 变化)做一次
+ // 「生长」:从 0 缩放到正常大小,并留下余晖。
+ //
+ // 全部在渲染循环里推进,不额外起定时器。
+ var SM_PULSE_MS = 1400; // 单个脉冲的生命期
+ var SM_RIPPLE_R = 26; // 涟漪最大半径
+
+ // starmapPulse 发出一次活动脉冲。
+ // kind: "tool" | "stage" | "output" | "grow"
+ function starmapPulse(kind, hint) {
+ if (!starmapScene || !starmapNodeMeshes.length) return;
+ var now = Date.now();
+ state.starmapLastPulseAt = now;
+ // 有 hint(工具名/阶段名)时优先点亮名字与提示相关的节点,
+ // 这是「图在跟 agent 动」最直接的体现:调了 knowledge_* 就亮知识节点。
+ var targets = starmapPickPulseTargets(hint);
+ if (!targets.length) return;
+ var life = kind === "grow" ? SM_PULSE_MS * 1.6 : SM_PULSE_MS;
+ targets.forEach((m) => {
+ state.starmapPulses.push({ mesh: m, until: now + life, kind: kind });
+ });
+ // 队列上限:防止密集工具调用时脉冲无限堆积占内存。
+ if (state.starmapPulses.length > 260)
+ state.starmapPulses = state.starmapPulses.slice(-260);
+ }
+
+ // starmapPickPulseTargets 选出该被点亮的节点。
+ // 优先名字/类型命中 hint 的;不足时按 mention_count 补齐(高权重节点
+ // 本身就是最常被 agent 触碰的,用它们代表「整体活动」合理)。
+ //
+ // ★ 匹配必须用「词」而不是子串包含:
+ // hint="knowledge" 与实体名 "k" / "e" 互为子串,会让半个图谱
+ // (含 "时"、"会" 这类单字实体)全部命中 ⇒ 脉冲退化成「全图齐亮」,
+ // 既看不出关联,又把队列瞬间打满。浏览器实测:旧写法一次 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;
+ }
+ }
+ }
+ }
+ }
+ // ★ 只有「一个都没匹配上」时才用全局权重节点代表「整体活动」。
+ // 实测(浏览器里跑真数据):词匹配对 knowledge_list 只命中 1 个,
+ // 但旧的补齐逻辑会把它补到 20 个 —— 于是脉冲看起来仍然是「一大片
+ // 无关节点在亮」,与要修的子串 bug 效果一样,只是换了个成因。
+ // 补齐只保留在真正无匹配的场景(hint 为空,如调度器脉冲)。
+ if (out.length > 0) return out;
+ // 补齐:按 mention_count 降序取靠前且尚未入列的。
+ var sorted = starmapNodeMeshes.slice().sort((a, b) => (
+ (b.userData.nodeData || {}).mention_count -
+ (a.userData.nodeData || {}).mention_count
+ ));
+ for (var j = 0; j < sorted.length && out.length < 8; j++) {
+ if (out.indexOf(sorted[j]) === -1) out.push(sorted[j]);
+ }
+ return out;
+ }
+
+ // starmapHintTokens 把提示词(工具名/阶段名)拆成可匹配的词元。
+ // 例:"knowledge_list" → ["knowledge","list"]。
+ // 只保留长度 >= 3 的词:单/双字母词("a"/"ls")几乎必然撞上无关
+ // 实体名,匹配它们只会制造噪声。
+ 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;
+ }
+
+ // starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。
+ function starmapPullActivity() {
+ if (!starmapScene) return;
+ api("/runtime")
+ .then((rt) => {
+ if (!rt || !rt.scheduler) return;
+ var s = rt.scheduler;
+ var prev = state.starmapActivity;
+ state.starmapActivity = s;
+ var pend =
+ (s.ready_queue_depth || 0) +
+ (s.pending_interrupts || 0) +
+ (s.suspend_stack || 0);
+ // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。
+ if (pend > 0) starmapPulse("stage", null);
+ // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。
+ if (prev) {
+ var dInt =
+ (s.interrupts_by_level || []).reduce((a, b) => a + b, 0) -
+ (prev.interrupts_by_level || []).reduce((a, b) => a + b, 0);
+ var dPre =
+ (s.preempts_by_level || []).reduce((a, b) => a + b, 0) -
+ (prev.preempts_by_level || []).reduce((a, b) => a + b, 0);
+ if (dInt > 0 || dPre > 0) starmapPulse("output", null);
+ }
+ })
+ .catch(() => {});
+ }
+
+ // starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。
+ function starmapPullPulse() {
+ if (!starmapScene) return;
+ var since = state.starmapPulseSince || 0;
+ api("/memory/graph/pulse?since=" + since)
+ .then((r) => {
+ if (!r || !r.success || !r.data) return;
+ var now = Math.floor(Date.now() / 1000);
+ state.starmapPulseSince = now - 30; // 30s 重叠,防跨轮漏节点
+ var nodes = r.data.nodes || [];
+ if (!nodes.length) return;
+ var known = 0;
+ nodes.forEach((n) => {
+ var m = starmapNodeMeshes.find((x) => x.userData.nodeId === n.id);
+ if (!m) return; // 全量图里没有(可能刚创建)⇒ 忽略,等下次全量
+ known++;
+ state.starmapGrown[m.userData.nodeId] = Date.now() + SM_PULSE_MS * 2;
+ starmapPulse("grow", n.name);
+ });
+ // 若有新实体但一个都没匹配上,说明全量图过期了,
+ // 下一拍重拉全量(新节点才能出现)。
+ if (known === 0 && nodes.length > 2) starmapDirty = true;
+ })
+ .catch(() => {});
+ }
+
+ // starmapTickActivity 在渲染循环里推进所有脉冲与余晖。
+ function starmapTickActivity(t) {
+ var now = Date.now();
+ var breathe = 0;
+ // 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);
+ 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;
+ 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((x) => x.userData.nodeId == gid);
+ if (gm)
+ gm.material.emissiveIntensity = Math.max(
+ gm.material.emissiveIntensity,
+ gm.userData.baseEmissive + 0.6,
+ );
+ }
+ }
+ // 3) 全局呼吸:距上次活动越近越亮,实现「agent 一忙图就活」
+ var idle = (now - (state.starmapLastPulseAt || 0)) / 4000;
+ breathe = Math.max(0, 1 - idle);
+ if (breathe > 0.01 && starmapNodeMeshes.length) {
+ // 只抽样一部分节点做呼吸,避免每帧改 1151 个材质。
+ var stride = 24;
+ for (var b = 0; b < starmapNodeMeshes.length; b += stride) {
+ var m2 = starmapNodeMeshes[b];
+ if (m2.userData.baseEmissive === undefined) continue;
+ m2.material.emissiveIntensity = Math.max(
+ m2.userData.baseEmissive,
+ m2.userData.baseEmissive + breathe * 0.25,
+ );
+ }
+ }
+ return breathe;
+ }
+
function createStarField() {
var c = 3000;
var p = new Float32Array(c * 3),
@@ -4872,18 +5173,153 @@
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 会把大节点缩成最小尺寸。
+ // (这是低规格改造后必须跟着改的一处,旧代码能“跑”是因为那时
+ // 几何体本身就带半径、不靠 scale。)
+ if (starmapHovered)
+ starmapHovered.scale.setScalar(
+ starmapHovered.userData.baseScale || 1,
+ );
starmapHovered = n;
- n.scale.set(1.2, 1.2, 1.2);
+ starmapLabelShow(n);
}
} else {
if (starmapHovered) {
- starmapHovered.scale.set(1, 1, 1);
+ starmapHovered.scale.setScalar(
+ starmapHovered.userData.baseScale || 1,
+ );
starmapHovered = null;
+ starmapLabelHide();
}
}
}
+ // starmapLabelShow/Hide:hover 时在容器角上显示一个 HTML 角标。
+ // 取代原先每节点一张 256x64 CanvasTexture(1151 张贴图 ≈ 72MB 显存),
+ // 文字用 DOM 渲染反而更清楚,且零 GPU 开销。
+ function starmapLabelShow(mesh) {
+ var cont = starmapActiveContainer();
+ if (!cont) return;
+ if (!starmapLabelEl) {
+ starmapLabelEl = document.createElement("div");
+ starmapLabelEl.id = "sm-label";
+ }
+ // 角标必须跟 canvas 在同一个容器里(它是绝对定位在容器上的)。
+ // canvas 换页签搬运时,角标也要跟着搬。
+ if (starmapLabelEl.parentElement !== cont) cont.appendChild(starmapLabelEl);
+ var nd = (mesh && mesh.userData && mesh.userData.nodeData) || {};
+ var nm = nd.name || nd.id || "";
+ starmapLabelEl.textContent =
+ nm + (nd.mention_count ? " ×" + nd.mention_count : "");
+ starmapLabelEl.style.display = "block";
+ }
+ function starmapLabelHide() {
+ if (starmapLabelEl) starmapLabelEl.style.display = "none";
+ }
+ // starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。
+ function starmapContainer() {
+ return (
+ document.getElementById("sm-container-home") ||
+ document.getElementById("sm-container-chat")
+ );
+ }
+
+ // starmapActiveContainer 选出「该把星图画在哪」的容器。
+ //
+ // 规则:先看独立星图页签,不存在(该页签还没打开过)就退回总览页,
+ // 再退回聊天面板。为什么要这个优先级:three.js 的 canvas 只能有一个
+ // 父节点,同时往两处渲染就会一边黑屏。每次切页签都把 canvas 搬到
+ // 当前该显示的地方,是单一 renderer 前提下最干净的做法。
+ function starmapActiveContainer() {
+ var tab = document.getElementById("tab-starmap");
+ if (tab && tab.classList.contains("active")) {
+ var c = document.getElementById("sm-container-page");
+ if (c) return c;
+ }
+ var ov = document.getElementById("tab-overview");
+ if (ov && ov.classList.contains("active")) {
+ var h = document.getElementById("sm-container-home");
+ if (h) return h;
+ }
+ return (
+ document.getElementById("sm-container-chat") ||
+ document.getElementById("sm-container-home") ||
+ null
+ );
+ }
+
+ // renderStarmapTab 渲染独立星图页签(只建一次骨架,不重复重建)。
+ function renderStarmapTab() {
+ var host = document.getElementById("tab-starmap");
+ if (!host) return;
+ if (!document.getElementById("sm-container-page")) {
+ var rt = state.runtime || {};
+ var sc = rt.scheduler || {};
+ var pend =
+ (sc.ready_queue_depth || 0) + (sc.pending_interrupts || 0);
+ host.innerHTML =
+ ' ' +
+ __("记忆星图", "Memory Star Map") +
+ ' ' +
+ ' ' +
+ ' ' +
+ smLegend("person", __("人物", "Person")) +
+ smLegend("concept", __("概念", "Concept")) +
+ smLegend("object", __("对象", "Object")) +
+ smLegend("location", __("地点", "Location")) +
+ smLegend("source", __("来源", "Source")) +
+ " " +
+ ' ' +
+ __(
+ "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。",
+ "The map pulses with agent activity: tool calls, stage progress, output, scheduler load, new memory.",
+ ) +
+ " ";
+ }
+ // 页签每次激活都把 canvas 搬过来 + 重新按容器尺寸 resize。
+ if (document.getElementById("sm-container-page")) {
+ if (!state.starmapInit && !state.starmapLoading)
+ loadChatStarmapData();
+ else if (starmapRen) {
+ var cont = document.getElementById("sm-container-page");
+ if (starmapRen.domElement.parentElement !== cont) {
+ cont.appendChild(starmapRen.domElement);
+ starmapRen.domElement.style.display = "block";
+ }
+ onStarmapResize();
+ }
+ }
+ smUpdateStat();
+ }
+
+ // smLegend 生成图例小项。
+ function smLegend(key, label) {
+ var col = smTypeColors[key] || 0xcccccc;
+ var hex = "#" + ("0000" + col.toString(16)).slice(-6);
+ return (
+ ' ' +
+ '' +
+ escHtml(label) +
+ ""
+ );
+ }
+
+ // smUpdateStat 在星图页签头部显示节点/边/活动状态。
+ function smUpdateStat() {
+ var el = document.getElementById("sm-page-stat");
+ if (!el) return;
+ var act = state.starmapActivity;
+ var busy = act && (act.ready_queue_depth || act.pending_interrupts);
+ var txt = starmapNodes.length + " " + __("节点", "nodes");
+ if (starmapEdges.length) txt += " / " + starmapEdges.length + " " + __("关系", "edges");
+ if (busy) txt += " · " + __("调度中", "busy");
+ el.textContent = txt;
+ }
+
+
function onStarmapClick(e) {
if (!starmapRen || !starmapCam) return;
var rect = starmapRen.domElement.getBoundingClientRect();
@@ -4907,9 +5343,7 @@
function flyStarmapTo(nodeId, dur) {
if (!starmapAutoView) return;
- var m = starmapNodeMeshes.find(function (x) {
- return x.userData.nodeId === nodeId;
- });
+ var m = starmapNodeMeshes.find((x) => x.userData.nodeId === nodeId);
if (!m) return;
var tp = m.position.clone(),
sp = starmapCam.position.clone(),
@@ -4919,7 +5353,7 @@
var t0 = Date.now();
(function lerp() {
var t = Math.min((Date.now() - t0) / dur, 1),
- e = 1 - Math.pow(1 - t, 3);
+ e = 1 - (1 - t) ** 3;
starmapCam.position.lerpVectors(sp, ep, e);
starmapCtrl.target.lerpVectors(st, tp, e);
if (t < 1) requestAnimationFrame(lerp);
@@ -4944,6 +5378,8 @@
starmapRaf = requestAnimationFrame(starmapAnimate);
if (starmapCtrl) starmapCtrl.update();
if (starmapStarField) starmapStarField.rotation.y += 0.0001;
+ // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。
+ if (starmapNodeMeshes.length) starmapTickActivity();
if (starmapRen && starmapScene && starmapCam)
starmapRen.render(starmapScene, starmapCam);
}
@@ -5007,16 +5443,16 @@
var ap = document.createElement("span");
ap.textContent = __("外观", "Appearance");
if (state.selectedSection === "appearance") ap.className = "active";
- ap.onclick = function () {
+ ap.onclick = () => {
state.selectedSection = "appearance";
renderOneSettings();
};
el.appendChild(ap);
- state.settingsPlugins.forEach(function (p) {
+ state.settingsPlugins.forEach((p) => {
var a = document.createElement("span");
a.textContent = pluginDisplayName(p);
if (p === state.selectedSection) a.className = "active";
- a.onclick = function () {
+ a.onclick = () => {
state.selectedSection = p;
renderOneSettings();
};
@@ -5031,7 +5467,7 @@
var theme = document.documentElement.getAttribute("data-theme") || "light";
var color = localStorage.getItem("ha-color") || "mono";
var dots = "";
- Object.keys(PALETTES).forEach(function (k) {
+ Object.keys(PALETTES).forEach((k) => {
dots +=
' ';
@@ -5078,9 +5514,7 @@
}
var prefix = state.selectedSection + ".";
var allKeys = Object.keys(state.settings || {});
- var filtered = allKeys.filter(function (k) {
- return k === prefix.slice(0, -1) || k.startsWith(prefix);
- });
+ var filtered = allKeys.filter((k) => k === prefix.slice(0, -1) || k.startsWith(prefix));
filtered.sort();
var hideTopLlms = [
"core.llm.base_url",
@@ -5090,36 +5524,30 @@
"core.llm.adapter_path",
"core.llm.thinking_enabled",
];
- var sourceKeys = filtered.filter(function (k) {
- return k.startsWith("core.llm.sources.");
- });
+ var sourceKeys = filtered.filter((k) => k.startsWith("core.llm.sources."));
var sourceMap = {};
- sourceKeys.forEach(function (k) {
+ sourceKeys.forEach((k) => {
var parts = k.split(".");
var srcName = parts[3];
if (!sourceMap[srcName]) sourceMap[srcName] = {};
sourceMap[srcName][k] = true;
});
- var mcpServerKeys = filtered.filter(function (k) {
- return (
+ var mcpServerKeys = filtered.filter((k) => (
k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5
- );
- });
+ ));
var mcpServerMap = {};
- mcpServerKeys.forEach(function (k) {
+ mcpServerKeys.forEach((k) => {
var parts = k.split(".");
var srvName = parts[3];
if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {};
mcpServerMap[srvName][k] = true;
});
- var regularKeys = filtered.filter(function (k) {
- return (
+ var regularKeys = filtered.filter((k) => (
!k.startsWith("core.llm.sources.") &&
hideTopLlms.indexOf(k) === -1 &&
!k.startsWith("plugin.mcp.servers.") &&
k !== "plugin.mcp.servers"
- );
- });
+ ));
var html =
' ';
if (
@@ -5135,7 +5563,7 @@
__("暂无设置项", "No settings") +
" ";
} else {
- regularKeys.forEach(function (k) {
+ regularKeys.forEach((k) => {
var v = state.settings[k];
var sv = typeof v === "object" ? JSON.stringify(v) : String(v);
var m = state.meta?.[k];
@@ -5161,7 +5589,7 @@
"";
} else if (typ === "select") {
var selOpts = "";
- opts.forEach(function (o) {
+ opts.forEach((o) => {
selOpts +=
' ";
Object.keys(mcpServerMap)
.sort()
- .forEach(function (srv) {
+ .forEach((srv) => {
var baseKey = "plugin.mcp.servers." + srv;
var fields = [
{
@@ -5419,7 +5847,7 @@
},
];
html += ' ' + escHtml(srv) + "";
- fields.forEach(function (f) {
+ fields.forEach((f) => {
var fk = baseKey + "." + f.key;
var fv = state.settings?.[fk] || "";
var fieldId = "inp-" + fk.replace(/\./g, "_");
@@ -5561,17 +5989,15 @@
adapter_path: "",
thinking_enabled: "false",
};
- var promises = keys.map(function (f) {
- return api("/settings", {
+ var promises = keys.map((f) => api("/settings", {
method: "PUT",
body: JSON.stringify({
key: "core.llm.sources." + name + "." + f,
value: values[f],
}),
- });
- });
+ }));
Promise.all(promises)
- .then(function () {
+ .then(() => {
toast(
__("源", "Source") +
' "' +
@@ -5584,7 +6010,7 @@
);
renderAll();
})
- .catch(function (e) {
+ .catch((e) => {
toast(__("创建失败: ", "Create failed: ") + e.message, true);
});
}
@@ -5639,17 +6065,15 @@
}
var fields = ["command", "url", "args", "env"];
var values = { command: "", url: "", args: "[]", env: "[]" };
- var promises = fields.map(function (f) {
- return api("/settings", {
+ var promises = fields.map((f) => api("/settings", {
method: "PUT",
body: JSON.stringify({
key: "plugin.mcp.servers." + name + "." + f,
value: values[f],
}),
- });
- });
+ }));
Promise.all(promises)
- .then(function () {
+ .then(() => {
toast(
"MCP " +
__("服务器", "server") +
@@ -5660,7 +6084,7 @@
);
renderAll();
})
- .catch(function (e) {
+ .catch((e) => {
toast(__("创建失败: ", "Create failed: ") + e.message, true);
});
}
@@ -5727,7 +6151,7 @@
"" +
__("操作", "Actions") +
" | |
";
- adapters.forEach(function (a) {
+ adapters.forEach((a) => {
html +=
"
| " +
escHtml(a.name) +
@@ -5758,7 +6182,7 @@
"" +
'' +
+ '\nreturn {\n name = "openai",\n version = "1.0",\n transform_request = function(raw) ... end,\n transform_response = function(raw) ... end,\n}">' +
'";
@@ -5851,7 +6275,7 @@
renderConfigDisabled();
// 先加载历史再连 SSE:避免 SSE 事件先到与历史加载顺序不确定导致消息重复/丢失
- (async function () {
+ (async () => {
await loadChatHistory();
renderAll();
connectSSE();
@@ -5864,6 +6288,6 @@
// 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期
// 丢失的事件(尤其是非 WebUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。
// syncChatFromHistory 仅追加新消息 DOM 节点,不重建已有消息,无闪烁。
- setInterval(function () {
- syncChatFromHistory().catch(function(){});
+ setInterval(() => {
+ syncChatFromHistory().catch(()=> {});
}, 30000);
diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go
index f4f4a60..de412ca 100644
--- a/internal/plugins/webui/handler.go
+++ b/internal/plugins/webui/handler.go
@@ -479,6 +479,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory))
mux.HandleFunc("/api/v1/memory/", h.requireAPI(h.handleMemory))
mux.HandleFunc("/api/v1/memory/graph", h.requireAPI(h.handleMemoryGraph))
+ // 星图活动端点:只回最近变动的实体(几百字节 ~ 几 KB),
+ // 而不是每 10s 重拉 400KB 全量图谱。见 handleMemoryGraphPulse。
+ mux.HandleFunc("/api/v1/memory/graph/pulse", h.requireAPI(h.handleMemoryGraphPulse))
mux.HandleFunc("/api/v1/memory/context", h.requireAPI(h.handleMemoryContext))
mux.HandleFunc("/api/v1/memory/tools", h.requireAPI(h.handleMemoryTools))
mux.HandleFunc("/api/v1/memory/text", h.requireAPI(h.handleTextMemory))
diff --git a/internal/plugins/webui/handler_graph_pulse_test.go b/internal/plugins/webui/handler_graph_pulse_test.go
new file mode 100644
index 0000000..cfa4b3e
--- /dev/null
+++ b/internal/plugins/webui/handler_graph_pulse_test.go
@@ -0,0 +1,163 @@
+package webui
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
+ pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
+)
+
+// fakeGraphMemory 是一个最小 MemoryAPI 桩,只为把 GraphData 喂给 handler。
+type fakeGraphMemory struct {
+ data map[string]interface{}
+}
+
+func (f *fakeGraphMemory) Recall([]string, int) ([]pubsdk.Entity, []pubsdk.Relation, error) {
+ return nil, nil, nil
+}
+func (f *fakeGraphMemory) Commit([]pubsdk.Triple) error { return nil }
+func (f *fakeGraphMemory) Introspect() (map[string]interface{}, error) {
+ return nil, nil
+}
+func (f *fakeGraphMemory) MergeEntities(string, string) (int, error) { return 0, nil }
+func (f *fakeGraphMemory) Purge(map[string]string, string) (int, error) {
+ return 0, nil
+}
+func (f *fakeGraphMemory) GraphData() (map[string]interface{}, error) { return f.data, nil }
+
+// graphFixture 造一份与生产实例同构的图谱快照:
+// 节点带 updated_at(pulse 端点按它过滤),memory_blocks 带稠密 vector。
+func graphFixture() map[string]interface{} {
+ now := time.Now()
+ old := now.Add(-72 * time.Hour)
+ return map[string]interface{}{
+ "nodes": []map[string]interface{}{
+ {"id": 1, "name": "小宅", "type": "Concept", "mention_count": 228,
+ "created_at": old, "updated_at": now.Add(-8 * time.Hour)},
+ {"id": 2, "name": "刚刚学到的东西", "type": "Concept", "mention_count": 1,
+ "created_at": now, "updated_at": now},
+ },
+ "edges": []map[string]interface{}{},
+ "memory_blocks": []memory.MemoryBlock{
+ {ID: "b1", Modality: memory.BlockModality("text"), Text: "hi",
+ PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: now, UpdatedAt: now},
+ },
+ }
+}
+
+func newGraphHandler(m *fakeGraphMemory) *Handler {
+ return &Handler{memory: m}
+}
+
+// TestHandleMemoryGraph_StripsVector 钉住「不下发稠密向量」。
+//
+// 生产实测:8 块记忆的 vector 占 79,314 B / 408,146 B = 19%,而星图
+// (本接口唯一消费者)从不读 vector。这部分纯属白付带宽 + 堆内存。
+func TestHandleMemoryGraph_StripsVector(t *testing.T) {
+ h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
+ rr := httptest.NewRecorder()
+ h.handleMemoryGraph(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph", nil))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
+ t.Fatalf("response still contains vector field:\n%s", rr.Body.String())
+ }
+ // 其余字段必须还在(不能顺手把整个 memory_blocks 删掉)。
+ for _, want := range []string{`"payload_digest":"d1"`, `"id":"b1"`, `"nodes"`, `"edges"`} {
+ if !bytes.Contains(rr.Body.Bytes(), []byte(want)) {
+ t.Fatalf("response missing %s:\n%s", want, rr.Body.String())
+ }
+ }
+}
+
+// TestHandleMemoryGraphPulse_OnlyRecent 钉住 pulse 端点只回窗口内变动的节点。
+//
+// 这是星图「新记忆生长」的数据源;它必须比全量图谱小两个数量级。
+func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) {
+ h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)
+ h.handleMemoryGraphPulse(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ var resp struct {
+ Success bool `json:"success"`
+ Data struct {
+ Nodes []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ MentionCount int `json:"mention_count"`
+ } `json:"nodes"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if !resp.Success {
+ t.Fatalf("success = false")
+ }
+ // 默认窗口 900s:8h 前的「小宅」不该在里面,刚更新的应该在。
+ if len(resp.Data.Nodes) != 1 {
+ t.Fatalf("expected 1 recent node, got %d: %+v", len(resp.Data.Nodes), resp.Data.Nodes)
+ }
+ if resp.Data.Nodes[0].ID != 2 {
+ t.Fatalf("expected node id=2, got %d", resp.Data.Nodes[0].ID)
+ }
+}
+
+// TestHandleMemoryGraphPulse_SinceWidens 钉住 since 参数能放大窗口。
+func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) {
+ h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse?since=86400", nil)
+ h.handleMemoryGraphPulse(rr, req)
+ var resp struct {
+ Data struct {
+ Nodes []struct {
+ ID int64 `json:"id"`
+ } `json:"nodes"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(resp.Data.Nodes) != 2 {
+ t.Fatalf("since=86400 should include both nodes, got %d", len(resp.Data.Nodes))
+ }
+}
+
+// TestHandleMemoryGraphPulse_NoVector 钉住 pulse 响应同样不带 vector。
+func TestHandleMemoryGraphPulse_NoVector(t *testing.T) {
+ h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
+ rr := httptest.NewRecorder()
+ h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil))
+ if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
+ t.Fatalf("pulse response contains vector:\n%s", rr.Body.String())
+ }
+}
+
+// TestGraphDataForVisual_PassthroughWhenTypeMismatch 钉住断言失败时不吞数据。
+//
+// graphDataForVisual 用类型断言识别 memory_blocks。若 GraphData 换了
+// 返回类型(比如改成 []*MemoryBlock),断言不中时必须原样透传而不是
+// 把字段弄丢 —— 宁可多发 vector,也不能让整个图谱接口变空。
+func TestGraphDataForVisual_PassthroughWhenTypeMismatch(t *testing.T) {
+ in := map[string]interface{}{
+ "nodes": []map[string]interface{}{{"id": 1}},
+ "memory_blocks": []*memory.MemoryBlock{{ID: "x"}},
+ }
+ out := graphDataForVisual(in)
+ if out["memory_blocks"] == nil {
+ t.Fatalf("memory_blocks dropped on type mismatch")
+ }
+ if _, ok := out["nodes"]; !ok {
+ t.Fatalf("nodes dropped")
+ }
+}
diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go
index b96ad33..2eea229 100644
--- a/internal/plugins/webui/handler_memory.go
+++ b/internal/plugins/webui/handler_memory.go
@@ -9,9 +9,11 @@ import (
"path/filepath"
"strconv"
"strings"
+ "time"
"unicode/utf8"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
@@ -109,6 +111,77 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
})
}
+// graphBlockView 是 memory_blocks 的**瘦身**下发视图。
+//
+// 为什么要瘦身(实测生产实例 1151 节点 / 866 边):
+//
+// 原始 /memory/graph 响应 408,146 B,其中 memory_blocks[].vector 占 79,314 B
+// (19%)。那是稠密向量 —— 检索侧(SearchIn / 稠密召回)才需要它,
+// 而星图是本接口**唯一**消费者,它只画节点/连线,压根不读 vector。
+//
+// 更大的问题是量级:每多一块记忆就多一份向量。8 块已经 79KB,
+// 200 块就是约 2MB 白白从库里查出来、序列化、走 socket、丢进浏览器堆,
+// 全程没有一行代码看过它。文本向量的维度还随模型走(数百到数千),
+// 换一次 embedder 就能让这个开销翻几倍。
+//
+// 所以这里显式裁掉 vector,而不是让 GraphData 返回值带个开关:
+// 本接口的语义就是「图谱的可视化数据」,让唯一调用方拿到它要的东西。
+type graphBlockView struct {
+ ID string `json:"id"`
+ Modality string `json:"modality"`
+ Text string `json:"text,omitempty"`
+ PayloadDigest string `json:"payload_digest"`
+ MIME string `json:"mime,omitempty"`
+ Size int64 `json:"size"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ Source string `json:"source,omitempty"`
+ Tool string `json:"tool,omitempty"`
+ Scene string `json:"scene,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// graphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。
+//
+// 用 map 断言而不是泛型/反射:GraphData 返回 map[string]interface{},
+// 里面的具体类型是包内私有的 graphEntity/[]*memory.MemoryBlock,
+// 断言不中就原样透传(宁可多发也不让接口挂掉)。
+func graphDataForVisual(data map[string]interface{}) map[string]interface{} {
+ out := make(map[string]interface{}, len(data))
+ for k, v := range data {
+ out[k] = v
+ }
+ blocks, _ := out["memory_blocks"].([]memory.MemoryBlock)
+ if blocks == nil {
+ // 可能是 []*memory.MemoryBlock 或空;两种都不是就直接跳过裁剪。
+ return out
+ }
+ views := make([]graphBlockView, 0, len(blocks))
+ for i := range blocks {
+ b := blocks[i]
+ views = append(views, graphBlockView{
+ ID: b.ID,
+ Modality: string(b.Modality),
+ Text: b.Text,
+ PayloadDigest: b.PayloadDigest,
+ MIME: b.MIME,
+ Size: b.Size,
+ Width: b.Width,
+ Height: b.Height,
+ Fingerprint: b.Fingerprint,
+ Source: b.Source,
+ Tool: b.Tool,
+ Scene: b.Scene,
+ CreatedAt: b.CreatedAt,
+ UpdatedAt: b.UpdatedAt,
+ })
+ }
+ out["memory_blocks"] = views
+ return out
+}
+
func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -123,9 +196,79 @@ func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
- writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data})
+ writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": graphDataForVisual(data)})
}
+// handleMemoryGraphPulse 是给星图「跟随 agent 动」用的**轻量**活动端点。
+//
+// 为什么不让星图反复拉完整 /memory/graph 做对比:
+//
+// 完整图谱生产实例 408KB(瘦身前 408KB→瘦身后约 329KB,仍含 1151 个节点
+// 和 866 条边的全量 JSON)。为了「知道哪些节点是新的」而每 N 秒拉一次全量,
+// 是把带宽和 JSON.parse 全花在重复数据上。
+//
+// 这里只回「最近 since 秒内变动过的实体」,字段压到最小(id + name +
+// mention_count + updated_at),实测是几百字节到几 KB 的量级 ——
+// 与完整图谱差两个数量级。新节点「生长」出来、老节点被再次提及而计数变化,
+// 都能从这份清单里看出来。
+//
+// since 缺省给 900s(15 分钟):略大于星图轮询周期(10s),
+// 即使客户端漏掉几个周期也能自愈,不必担心漏掉节点。
+func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if h.memory == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"})
+ return
+ }
+ since := time.Now().Add(-defaultGraphPulseWindow)
+ if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" {
+ if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
+ since = time.Now().Add(-time.Duration(sec) * time.Second)
+ }
+ }
+ data, err := h.memory.GraphData()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ out := graphDataForVisual(data)
+ // 过滤出窗口内变动过的节点。GraphData 的 nodes 是 []graphEntity(私有类型),
+ // 这里用重新序列化的方式裁剪:字段少、无向量、且不依赖私有类型断言。
+ // 成本是「再序列化一次节点」,但相比把 400KB 发出去仍然划算得多。
+ rawNodes, _ := json.Marshal(out["nodes"])
+ var nodes []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ MentionCount int `json:"mention_count"`
+ UpdatedAt time.Time `json:"updated_at"`
+ }
+ _ = json.Unmarshal(rawNodes, &nodes)
+ pulse := make([]map[string]interface{}, 0, 8)
+ for _, n := range nodes {
+ if n.UpdatedAt.Before(since) {
+ continue
+ }
+ pulse = append(pulse, map[string]interface{}{
+ "id": n.ID,
+ "name": n.Name,
+ "type": n.Type,
+ "mention_count": n.MentionCount,
+ "updated_at": n.UpdatedAt,
+ })
+ }
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "success": true,
+ "data": map[string]interface{}{"nodes": pulse},
+ })
+}
+
+// defaultGraphPulseWindow 是 /memory/graph/pulse 不带 since 时的回看窗口。
+const defaultGraphPulseWindow = 900 * time.Second
+
// knowledgeWriteReq 是知识写入请求体(JSON 分支)。
type knowledgeWriteReq struct {
Name string `json:"name"`
|