From 9c93f232e33ada5d41f5339de37cd9b0b97edda1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 07:14:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E6=98=9F=E5=9B=BE=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E6=94=B9=E4=B8=AD=E6=80=A7=E7=81=B0=E8=93=9D=20+=20?= =?UTF-8?q?=E5=9B=BE=E4=BE=8B=E6=8C=89=E5=AE=9E=E9=99=85=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 为什么会出现「一片绿」 上一提交修好了「分类配色从未生效」那个 bug(服务端发 "Concept"、JS 键是 "concept" ⇒ 永不匹配 ⇒ 1150 个节点全回退兜底灰 0xcccccc)。修好之后 颜色值**一个字没改**(concept 键仍是 0x44ff88),于是 1148 个 Concept 节点第一次真的拿到了那个亮青绿 —— 1149 个节点里 1148 个是 Concept, 所以整张图变成绿的。 ⇒ 结论:绿色不是渲染 bug,是「配色终于生效」后暴露出的真实数据形状。 之前的灰色恰好是「全都没匹配上」的症状。 ### 换中性色(用户裁定) 默认色改为 SM_COLOR_DIM = 0x7d8a9e(中性灰蓝)。亮青绿配 1149 个 自发光球确实扎眼。 ### 顺带把「图例说谎」也修了 原图例写死「人物/概念/对象/地点/来源」五项,而实测图谱里只有 Concept(1148)与 Source(1)—— 列出四个永不出现的类型,等于告诉 用户存在实际不存在的分类。 现在: - 图例按**实际出现**的类型动态生成,标注占比 - 占比 <1% 的不单列(实测 1/1149 = 0.087%,单列会显示成「来源 0%」, 既难看又误导读者以为图里没有来源节点),归入「其他 N 个」 - 只有**一种有存在感**的类型时,附一句实话说明「节点同色不是分类图」 ### 颜色规则也跟图例对齐 smColorFor:只有存在 >=1% 的第二类型时才按类型上色,否则全图中性色。 理由:99.9% 概念 + 0.1% 其他时按类型上色,得到的仍是一整片同色, 而那一两个异色点在视觉上就是噪点(实测 colors 只剩 ["7d8a9e"])。 不动服务端类型识别(用户裁定):nlp/extractor.go 至今不判类型、 graph.go:451 写死 Concept,根治要改记忆链路,本轮不碰。 ### 途中修掉一个自己引入的 bug 图例空。首屏星图在**总览页**初始化,那时 #sm-legend 还不存在 (骨架由 renderStarmapTab 建),smLegend 内部 getElementById 返回 null 直接返回;而 renderStarmapTab 建完骨架后只调了 smUpdateStat()。 ⇒ 浏览器实测图例 html 长度 0。在 renderStarmapTab 里补一次 smLegend()。 ### 验证(真实 1149 节点数据集 + WebGL) - 颜色:["7d8a9e"](单一中性色)—— 改前 ["44ff88"] 一片绿 - 图例文本:「概念 100% 其他 1 个(图谱实体几乎都是同一类型,节点同色; 出现新类型后会自动分类)」 - 统计:1149 节点 / 865 关系;控制台零异常 - go vet 干净;全量测试通过 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/plugins/webui/dashboard.js | 183 +++++++++++++++++++++++----- 1 file changed, 150 insertions(+), 33 deletions(-) diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 6823691..2597ca3 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -2936,6 +2936,9 @@ function initChatStarmap() { // 活动数据源:/runtime 3s + /memory/graph/pulse 10s。 // 只在星图真正初始化后启动,避免在隐藏页签空跑。 starmapStartActivity(); + // 图例按实际出现的类型生成(建图后 smPresentTypes 才是准的)。 + smLegend(); + smUpdateStat(); } // starmapStartActivity 启动两路活动轮询(幂等)。 @@ -2954,6 +2957,13 @@ function starmapStartActivity() { } function buildChatStarmapGraph() { + // 先统计实际出现的类型(smColorFor / 动态图例都要用)。 + // 必须在建节点**之前**算完 —— 颜色在创建 mesh 时就定下了。 + smPresentTypes = {}; + starmapNodes.forEach((n) => { + var t = String(n.type || "").toLowerCase() || "(未知)"; + smPresentTypes[t] = (smPresentTypes[t] || 0) + 1; + }); starmapNodeMeshes.forEach((m) => { starmapScene.remove(m); }); @@ -3113,7 +3123,7 @@ function buildChatStarmapGraph() { // 服务端 type 是首字母大写("Concept" / "Person" …), // 原 smTypeColors 的键全是小写,永远匹配不上 ⇒ 全图单色 0xcccccc。 // 这里统一小写归一化,并补上服务端实际会产出的类型。 - var col = smTypeColors[String(n.type || "").toLowerCase()] || 0xcccccc; + var col = smColorFor(n.type); var ei = 0.3 + mnr * 0.7; var mat = new THREE.MeshPhongMaterial({ color: col, @@ -5468,21 +5478,60 @@ var starmapLabelEl = null; // smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。 // 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go), // 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc, -// 分类配色实际上从未生效过。 +// 分类配色实际上从未生效过。现已加小写归一化,映射是真的生效了。 +// +// ★ 但色相换成**中性灰蓝**(用户裁定):亮青绿(0x44ff88)配 1151 个 +// 自发光球确实扎眼,而且它掩盖了一个更刺眼的事实 —— +// +// 实测图谱里 Concept 占 1148/1149 = 99%(nlp/extractor.go 完全不判类型, +// graph.go:451 在 type 为空时写死 "Concept")。也就是说这套按类型 +// 配色的设计,对真实数据而言**只有一种颜色会被用到**。 +// +// 所以真正的做法不是挑一个好看的色,而是: +// 1. 默认色改为低饱和灰蓝(全图统一,不假装在分类) +// 2. 类型色只在**真的存在多种类型**时才按类型区分(见 smColorFor) +// 3. 图例按实际节点集合动态生成,不列出永不出现的类型 +var SM_COLOR_DIM = 0x7d8a9e; // 中性灰蓝:单一类型时的全图色 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, + // 以下类型在真实数据里几乎不出现(仅 social.go 会产出 person), + // 但保留定义:万一出现就能自动获得区分色 + 动态图例条目。 + person: 0x6f9fd8, + task: 0xd89a6a, + ai: 0xa583d8, + // 绝大多数节点(Concept):用中性灰蓝,不在“分类色”里扮浓。 + concept: SM_COLOR_DIM, + object: 0xd87a7a, + location: 0xd8b06a, + source: 0x8b9dc4, + document: 0x9aa5b1, + event: 0xc98fb5, + entity: 0x7fb5ad, + scene: 0x9dc47f, }; + +// smColorTypeSet 统计本次图谱里实际出现的类型(小写)。 +// 由 buildChatStarmapGraph 在建图前填好。 +var smPresentTypes = {}; + +// smColorFor 取节点颜色。 +// +// 规则:图谱里存在 2 种以上「有存在感」的类型时按类型上色,否则全图 +// 用 SM_COLOR_DIM。阈值与图例一致(>=1% 算“有存在感”)。 +// 理由:99.9% 概念 + 0.1% 其他时,按类型上色得到的仍是一整片同色, +// 只是换了个色相;而那一两个异色点在视觉上就是噪点。 +function smColorFor(type) { + var t = String(type || "").toLowerCase(); + var keys = Object.keys(smPresentTypes); + if (keys.length <= 1) return SM_COLOR_DIM; + var total = 0; + for (var i = 0; i < starmapNodes.length; i++) total++; + var self = (smPresentTypes[t] || 0) * 100; + if (total > 0 && self / total < 1) { + // 稀疏类型也走中性色:宁可全图同色,不引入单点亮色噪点。 + return SM_COLOR_DIM; + } + return smTypeColors[t] || SM_COLOR_DIM; +} var smEdgeColors = { 喜欢: 0xff6b6b, 学习: 0x4ecdc4, @@ -5866,13 +5915,7 @@ function renderStarmapTab() { __("记忆星图", "Memory Star Map") + ' ' + '
' + - '
' + - smLegend("person", __("人物", "Person")) + - smLegend("concept", __("概念", "Concept")) + - smLegend("object", __("对象", "Object")) + - smLegend("location", __("地点", "Location")) + - smLegend("source", __("来源", "Source")) + - "
" + + '
' + '
' + __( "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。", @@ -5892,21 +5935,95 @@ function renderStarmapTab() { onStarmapResize(); } } + // ★ 图例也要在这里重算,而不只是 initChatStarmap 里算一次: + // 首屏时星图是在**总览页**初始化的(那时 #sm-legend 还不存在, + // smLegend 内部 getElementById 返回 null 直接返回),而本函数的 + // 容器骨架恰恰是此刻才建的。不在这里补一次,图例就永远是空的。 + smLegend(); smUpdateStat(); } -// smLegend 生成图例小项。 -function smLegend(key, label) { - var col = smTypeColors[key] || 0xcccccc; - var hex = "#" + ("0000" + col.toString(16)).slice(-6); - return ( - '' + - '' + - escHtml(label) + - "" - ); +// smLegendLabel 把内部类型名转成可读标签。 +var SM_TYPE_LABELS = { + person: ["人物", "Person"], + concept: ["概念", "Concept"], + object: ["对象", "Object"], + location: ["地点", "Location"], + source: ["来源", "Source"], + document: ["文档", "Document"], + event: ["事件", "Event"], + entity: ["实体", "Entity"], + scene: ["场景", "Scene"], + ai: ["AI", "AI"], + task: ["任务", "Task"], +}; + +// smLegend 按**实际出现**的类型生成图例。 +// +// ★ 为什么不用固定列表:原图例写死了「人物/概念/对象/地点/来源」五项, +// 而实测图谱里只有 Concept(1148)与 Source(1)。列出四个永不出现的 +// 类型 = 对用户说谎(图例说“分类”,实际是一整片同色)。 +// +// 现在: +// - 只有一种类型 → 只显示一项,并标注占比(如「概念 1149」) +// - 多种类型 → 按数量降序列出全部,每项带占比 +// - 类型名不认识 → 退化为小写原文而不是默默消失 +function smLegend() { + var box = document.getElementById("sm-legend"); + if (!box) return; + var keys = Object.keys(smPresentTypes); + if (!keys.length || !starmapNodes.length) { + box.innerHTML = ""; + return; + } + var total = starmapNodes.length; + keys.sort((a, b) => smPresentTypes[b] - smPresentTypes[a]); + // 「主要类型」= 占比 >= 1% 的。实测 1148 Concept + 1 Source 时, + // Source 占 0.087% —— 直接列出来会显示成「来源 0%」,既难看又误导 + //(读者会以为图里没有来源节点)。低于 1% 的归入「其他 N 个」。 + var MIN_SHOW_PCT = 1; + var major = []; + var minor = 0; + keys.forEach((k) => { + if ((smPresentTypes[k] * 100) / total >= MIN_SHOW_PCT) major.push(k); + else minor += smPresentTypes[k]; + }); + var dim = major.length <= 1; // 颜色上是否走单一色 + var html = major + .map((k) => { + var lbl = SM_TYPE_LABELS[k]; + var label = lbl ? __(lbl[0], lbl[1]) : k; + var hex = "#" + ("0000" + smColorFor(k).toString(16)).slice(-6); + var pct = Math.round((smPresentTypes[k] * 100) / total); + return ( + '' + + '' + + escHtml(label) + + ' ' + + pct + + "%" + ); + }) + .join(""); + if (minor > 0) + html += + '' + + __("其他 ", "other ") + + minor + + __(" 个", " nodes") + + ""; + // 颜色上实际是单一色时,说清楚这不是分类图。 + if (dim) + html += + '' + + __( + "(图谱实体几乎都是同一类型,节点同色;出现新类型后会自动分类)", + "(entities are almost all one type, so nodes share a color; new types will be color-coded automatically)", + ) + + ""; + box.innerHTML = html; } // smUpdateStat 在星图页签头部显示节点/边/活动状态。