feat(webui): 星图切到块体系数据源 + 全部标名/灰调/读写反馈/三种变化动画

## 根因:记忆系统已迁移,前端没跟上

`GraphData()` 同时返回两套数据,而前端**只读旧的**:

    nodes/edges                ← 旧表 entities/relations(2026-10-04 停写)
    memory_blocks/block_edges  ← 块/边体系(真实活跃的记忆)

生产实测:`entities` 停更在 2026-10-03(1294 条),而 `memory_blocks`
同期从 1294 涨到 3187。写入函数 `upsertEntity` 已成**全仓零调用的孤儿**。
⇒ 星图结构性看不到任何新记忆。

(另修的同类问题:`starmapDirty` 只写不读且未声明,赋值成隐式全局 ⇒
即使后端发事件,新块也永远不会出现在图上。见下「判据」。)

## 数据源切换

新增 `smAdaptGraph()` 把块/边归一化为星图节点/边(字段与旧格式一致,
下游建图/布局/图例/脉冲无需各自判源):
- `mention_count` 回填为**度数**(块表无此列,而它驱动节点大小/标签优先级)
- 只画 `status` 为 active/空 的边(deleted/merged 的边留着会让失效关系看着还在)
- 端点缺失的边丢弃(画不出,且会让度数统计偏大)

## 视觉(按「全部标名 + 灰调 + 悬浮提亮 + 查询提亮 + 三种变化动画」)

**全部节点名**:新增标签覆盖层 —— **一个** 2D canvas 画全部名字,而非
每节点一张 CanvasTexture(原实现 1151 张 ≈ 72MB 显存,也是它当初被删的原因)。
三级可读性:活动/悬浮=实色+底块,其余=低透明当底纹;**同屏文本去重**
(3178 块里大量同文本块,不去重会叠成十几层同样的字)。
仅在相机或脉冲集合变化时重绘。

**灰色调**:`smGreyFor()` 全图灰阶,**亮度按度数**(亮处即记忆稠密处)。
原按类型上色对真实数据只用得上一种颜色(99% 同一类型)。

**悬浮提亮**:发光 +1.4 + 放大 1.25×(只放大不亮在淡底上不明显,只亮不放大
又看不出是哪颗)。

**查询提亮**:`memory_access` → `starmapPulseByIDs()`,按**真实块 ID** 精确命中。

**三种变化动画**(三种是**排他状态机**,不与可叠加的活动 pulse 混用):

| 动画 | 曲线 | 时长 |
|---|---|---|
| 插入生长 | easeOutBack 过冲(从 0.05 起,从 0 起看不出「长」) | 1800ms |
| 删除消散 | 收缩 + 变暗 + 球面均匀粒子飞散(减速淡出) | 1200ms |
| 合入 | 源消散 → **停顿 120ms** → 目标重生 | 1200+120+900ms |

合入刻意分两段且中间停顿:同时放会读成「一个变小一个变大」,
先看不到源、再看到目标冒出,「合入」的因果才成立。
粒子用**单个** Points 承载(上限 600),而非每粒子一个 mesh ——
批量删除(Purge 上百块)时后者会瞬间建上千对象。
消散完成必须真 `scene.remove()`,否则 scale=0 的不可见 mesh 仍可被鼠标拾中。

**顺带**:
- 补选中详情面板(`starmapSelected` 此前**只赋值、无人读取**,点击节点
  没有任何信息反馈 —— 「可读性差」的核心成因之一)
- 布局半径按 √n 自适应 + 相机初始距离自适应(3178 节点挤在旧的固定
  15–80 环带里会糊成一片)
- 图例改为说明**视觉编码**(大小/亮度/活动/三种动画),不再列不存在的类型色
- `starmapDirty` 消费点加 30s 节流(`/memory/graph` 实测 3.3MB,
  密集写入时每 10s 重拉是纯浪费)

## 判据

- `graph_source_test.go`:钉住后端**必须下发 memory_blocks**
  (前端星图的真实数据源),防未来又切回停写的 nodes
- `TestDashboardJS_NoWriteOnlyGlobals`(新增静态判据):**只写不读的全局布尔标志**
  ⇒ 这类「置了标志无人消费」是静默失效,真跑 node 也抓不到(隐式全局赋值合法)。
  该判据写出来就当场抓到 `starmapDirty`,并经变异验证有效
  (删掉消费点 → 判据点名报错)
- `node --check` + webui 全部判据绿
This commit is contained in:
JianFeeeee
2026-10-08 10:41:22 +08:00
parent 9036db9321
commit 69c9a9613f
5 changed files with 1136 additions and 94 deletions

View File

@ -2091,6 +2091,63 @@
#sm-container-home canvas {
display: block;
}
/* 节点名覆盖层:盖在 WebGL canvas 上,不吃鼠标事件(否则星图不能拖拽)。*/
#sm-labels {
pointer-events: none;
z-index: 4;
}
/* 选中详情面板:点击节点后的信息反馈。
★ 补它的原因:`starmapSelected` 此前只被赋值、无人读取,
点击节点在界面上没有任何信息反馈——“可读性差”的核心成因之一。*/
#sm-detail {
position: absolute;
right: 10px;
top: 10px;
display: none;
width: 232px;
max-height: 62%;
overflow-y: auto;
padding: 10px 12px;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
background: rgba(10, 10, 20, 0.88);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
z-index: 6;
/* 面板本身要能滚动,但不该把滚轮事件漏给下面的星图。*/
pointer-events: auto;
}
#sm-detail .sm-detail-title {
font-weight: 600;
margin-bottom: 2px;
word-break: break-all;
}
#sm-detail .sm-detail-meta,
#sm-detail .sm-detail-id {
color: var(--text-muted);
font-size: 11px;
margin-bottom: 4px;
word-break: break-all;
}
#sm-detail .sm-detail-rel {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 11px;
}
#sm-detail .sm-detail-relrow {
margin-bottom: 2px;
word-break: break-all;
}
#sm-detail .sm-detail-arrow {
color: var(--accent, #7fb5ad);
margin-right: 3px;
}
#sm-detail .sm-detail-nb {
color: var(--text-primary);
}
/* hover 角标:取代原先每节点一张 256x64 CanvasTexture
(1151 张贴图 ≈ 72MB 显存)。DOM 文字更清晰且零 GPU 开销。*/
#sm-label {

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
@ -303,3 +304,114 @@ func max(a, b int) int {
}
return b
}
// TestDashboardJS_NoWriteOnlyGlobals 钉住「只写不读的全局变量」这类静默失效。
//
// ★ 为什么需要(2026-10-08 实际踩到):`starmapDirty = true` 在 dashboard.js
// 里出现两处,但**没有任何地方读它**,而且它连声明都没有(赋的是隐式全局)。
// 后果是**新写入的记忆块永远不会出现在星图上** —— 后端的 memory_access
// 事件一路发到这里、标志位也置了,就是没人消费,整条链路静默断在这里。
//
// 这类 bug 的特征:不报错、不抛异常、UI 上看不出异常(图只是“不长”),
// 既有的作用域判据(真跑 node)也抓不到 —— 因为隐式全局赋值在非严格模式下
// 完全合法。所以必须用静态判据单独钉。
func TestDashboardJS_NoWriteOnlyGlobals(t *testing.T) {
raw := dashboardJSSource(t)
// ★ 必须先剥掉注释再做词频统计:注释里经常提到变量名(本项目尤其如此,
// 注释大量引用 `starmapDirty = true` 这类代码片段),不剥会让「出现次数」
// 虚高,把真正只写不读的变量算成有读。
js := stripJSComments(raw)
// 顶层 `var X` 声明过的名字。
declRe := regexp.MustCompile(`\bvar\s+([A-Za-z_$][\w$]*)`)
declared := map[string]bool{}
for _, m := range declRe.FindAllStringSubmatch(js, -1) {
declared[m[1]] = true
}
// 只关心布尔标志:赋 true/false 的全局。这类最典型的失效形态就是
// 「置了标志但没人消费」——功能看着接好了,实际没接。
for name := range declared {
nameRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`)
occur := len(nameRe.FindAllString(js, -1))
writeRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*(true|false)\b`)
writes := len(writeRe.FindAllString(js, -1))
if writes == 0 {
continue // 不是布尔标志,不在本判据范围
}
// 每次出现要么是一次写入、要么是一次读取。
// ⇒ 读取次数 = 出现次数 - 写入次数。等于 0 即「只写不读」。
reads := occur - writes
if reads <= 0 {
t.Errorf("全局布尔标志 %s 只被写入、从未被读取(出现 %d 次,写入 %d 次)。\n"+
" 这类标志位属于**静默失效**:置了标志但无人消费,功能看似接好了实际没接。\n"+
" 修法:补上消费点,或删掉这个字段。", name, occur, writes)
}
}
}
// stripJSComments 剥掉 // 行注释与 /* */ 块注释(粗粒度,但足够用于词频统计)。
//
// 不做完整的 JS 词法分析:本判据只用于「统计某个标识符出现次数」,
// 粗粒度剥离已经足够,且不会误伤字符串里的 //(本文件没有这类内容,
// 若将来有,宁可少剥也不能多剥 —— 多剥会让判据漏报)。
func stripJSComments(s string) string {
var b strings.Builder
b.Grow(len(s))
inLine, inBlock := false, false
var inStr byte
for i := 0; i < len(s); i++ {
c := s[i]
if inLine {
if c == '\n' {
inLine = false
b.WriteByte(c)
}
continue
}
if inBlock {
if c == '*' && i+1 < len(s) && s[i+1] == '/' {
inBlock = false
i++
}
continue
}
if c == '\n' {
b.WriteByte(c)
continue
}
if inStr != 0 {
b.WriteByte(c)
if c == '\\' {
if i+1 < len(s) {
b.WriteByte(s[i+1])
i++
}
continue
}
if c == inStr {
inStr = 0
}
continue
}
if c == '"' || c == '\'' || c == '`' {
// 反引号也是字符串定界符,但它可跨行 —— 本判据只统计标识符,
// 跨行模板串里出现标识符属正常,此处按普通字符串处理即可。
inStr = c
b.WriteByte(c)
continue
}
if c == '/' && i+1 < len(s) && s[i+1] == '/' {
inLine = true
i++
continue
}
if c == '/' && i+1 < len(s) && s[i+1] == '*' {
inBlock = true
i++
continue
}
b.WriteByte(c)
}
return b.String()
}

View File

@ -0,0 +1,46 @@
package webui
import (
"encoding/json"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
// 星图数据源必须来自块体系(memory_blocks),而不是停写的旧表 entities。
//
// 判据:真实图谱里 memory_blocks 远多于 entities,且块侧仍在增长。
// 这里用合成数据钉住「有块时节点数 == 块数」,防未来又切回 nodes。
func TestGraphDataExposesBlocksForStarmap(t *testing.T) {
// 模拟后端 GraphData 的返回:旧表 1 个节点,块表 3 个块 + 2 条边
data := map[string]interface{}{
"nodes": []map[string]interface{}{{"id": int64(1), "name": "旧表实体", "type": "Concept"}},
"edges": []map[string]interface{}{},
"memory_blocks": []memory.MemoryBlock{
{ID: "blk_src_a", Modality: memory.BlockText, Text: "张三喜欢咖啡", Source: "sentence"},
{ID: "blk_ent_b", Modality: memory.BlockText, Text: "张三", Source: "triple", SemanticType: "Person"},
{ID: "blk_ent_c", Modality: memory.BlockText, Text: "咖啡", Source: "triple"},
},
"memory_block_edges": []memory.MemoryBlockEdge{
{ID: 1, SourceKind: "block", SourceID: "blk_src_a", TargetKind: "block", TargetID: "blk_ent_b", Type: "contains"},
{ID: 2, SourceKind: "block", SourceID: "blk_src_a", TargetKind: "block", TargetID: "blk_ent_c", Type: "contains"},
},
}
out := graphDataForVisual(data)
// 前端读的是 memory_blocks 字段
blocks, ok := out["memory_blocks"]
if !ok {
t.Fatal("GraphData 必须下发 memory_blocks(前端星图的真实数据源)")
}
raw, _ := json.Marshal(blocks)
var got []graphBlockView
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("memory_blocks 应下发 3 个,实际 %d", len(got))
}
if _, hasNodes := out["nodes"]; !hasNodes {
t.Fatal("旧 nodes 字段应保留(向后兼容),只是前端不再依赖它")
}
}

View File

@ -9,8 +9,8 @@ import (
"time"
"encoding/json"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
"net/http"
)
@ -734,7 +734,17 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
// executeNewTask/resumeTask),而 `scheduler` 曾既不在本列表里、也没有任何
// 订阅方—— 事件发出来就掉地上。后果是「我的任务被谁打断了」对内对外都
// 不可见,排查时只能按时序猜。这里补上对外那一半。
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output", "scheduler"}
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output", "scheduler",
// memory_access:图记忆的真实读写(哪个块被读了/写了/删了/合了)。
//
// ★ 为何新增而不是用现有的 tool_call:tool_call 只带**工具名**
// (如 memory_recall),而星图要的是**具体块 ID**。二者之间没有映射 ——
// 前端曾试图把工具名拆词去匹配实体名,而实体名是中文概念、工具名是英文
// (实测每个工具名命中 0 个节点),只能回退到「点亮 mention_count 前 8 个」,
// 即每次工具调用都亮同一批无关节点。
//
// 事件本身很小(只含块 ID 数组,不含正文)—— 与 tool_call 同量级。
"memory_access"}
// token 级流式增量事件:实时转发给浏览器做逐 token 渲染。
// 不进 sseEventRing —— 断线重连只重放聚合事件(最终真相),
// 避免重放 delta 与聚合内容重复追加。