feat(webui): 星图跟随 agent 活动 + 搬到主页 + 修分类配色从未生效

星图此前只是静态展示:starmapAnimate() 只转星空,节点完全静止,
与 agent 的动作零关联。本轮三件事。

★ 修一个从未被发现的 bug:分类配色一直是坏的
  服务端 type 是首字母大写("Concept",见 internal/memory/graph.go),
  而 smTypeColors 的键全是小写 ⇒ 永远匹配不上 ⇒ 1151 个节点全渲染成
  同一个灰色 0xcccccc。浏览器实测确认:改前 colors=["cccccc"],
  改后 ["44ff88"](概念绿)。

一、跟随 agent 动(三路信号,全部在渲染循环里推进,不另起定时器)
  1. tool_call / stage / agent_output 的 SSE 事件 → 命中节点发光冲高
     + 尺寸微扩。工具名按**词**匹配实体(knowledge_list → knowledge_*)。
  2. /runtime 调度器(3s)→ 排队/中断/挂起时全图绷紧;中断或抢占计数
     上升时来一记强脉冲。
  3. /memory/graph/pulse(10s,新端点)→ 新记忆「生长」:从 0 弹到
     正常大小并留余晖。
  另:距上次活动越近,全图越亮(抽样呼吸)—— agent 一忙图就活。

二、搬到主页 + 独立页签
  总览页内嵌 360px 星图;顶部导航加「星图」独立页签(全高 + 图例)。
  同一套 renderer 用 appendChild 在容器间搬运 canvas(three.js 的
  canvas 只能有一个父节点,同时渲染会一边黑屏)。

三、性能:保留全部 1151 节点,但全部降规格
  改前每节点 = 独立 SphereGeometry(16,12) + 独立光晕球 + 一张 256x64
  CanvasTexture ⇒ 2302 个独立 geometry、约 88 万三角形、1151 个
  <canvas>,仅文字贴图就吃约 72MB 显存。全景远看根本读不清那些标签。
  改后:共享 SphereGeometry(8,6)(约 84 三角形/节点);标签改为 hover
  时在容器角上显示 HTML 文本(零显存,且比 3D 贴图更清晰);
  866 条边按关系类型合并成 4 个 LineSegments(draw call 866 → 4)。
  hover 复位随之改为 baseScale —— 旧的 set(1,1,1) 会把按 mention_count
  缩放过的大节点缩成最小尺寸。

四、/memory/graph 瘦身:不再下发稠密向量
  星图是本接口唯一消费者,却从不读 vector。生产实测该字段占
  79,314 / 402,811 字节 = 19%,而 8 块记忆就这么多,200 块就是 ~2MB
  白查白发白堆。真实数据集实测响应 402,811 → 326,997 字节(-18%)。

新增 /memory/graph/pulse:只回 since 窗口内变动过的实体(id/name/type/
mention_count/updated_at)。星图每 10s 拉它来判断「哪个节点新长出来」,
而不必重拉 400KB 全量。

验证(不是「应该能跑」):
  - go vet 干净;go test 全绿;新增 5 个测试(向量裁剪 / pulse 窗口 /
    since 放大 / pulse 不带向量 / 类型断言失败时透传不丢数据)
  - 覆盖率 65.5% → 66.5%
  - 真实 1151 节点数据集上跑 headless chromium + SwiftShader 实测:
    nodeMeshes=1151、edgeSegs=4、geoShared=true、控制台零报错、
    图例与统计(1151 节点 / 866 关系)正常、canvas 在两个容器间正确搬运
  - 脉冲匹配在浏览器里逐个 hint 验证:
      knowledge_list → 2 个(只命中 knowledge_base / knowledge_list)
      qq_get_message 等无匹配 → 8 个(走「整体活动」兜底)
    ★ 途中修掉自己的两个错:① 最早的子串匹配让 hint="knowledge" 命中
    全部单字实体(一次 pulse 选中 250 个、队列顶到 260 上限);
    ② 改成词匹配后,旧的「补齐到 20 个」逻辑又把 1 个真实命中补成 20 个
    无关节点 —— 现象与①一样,只是成因不同。补齐现在只在**完全无匹配**
    时启用。

未做(本轮范围外):服务端 gzip(首屏 793KB 无压缩,实测可压到 ~240KB)、
renderAll 按页签懒加载(首屏仍在拉隐藏页签的 kernel+settings 共 230KB)、
setInterval 15s 全量重拉(空闲 53MB/h)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JianFeeeee
2026-09-26 23:31:58 +08:00
parent bf5d8915dd
commit 9346fed0d9
6 changed files with 1027 additions and 253 deletions

View File

@ -2076,6 +2076,42 @@
#sm-container-chat canvas {
display: block;
}
/* 星图容器:独立页签 / 总览页 / 聊天面板三处共用同一套样式。
position:relative 是必需的 —— hover 角标(#sm-label)绝对定位在容器上。*/
#sm-container-page,
#sm-container-home {
background: var(--bg-input);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
width: 100%;
}
#sm-container-page canvas,
#sm-container-home canvas {
display: block;
}
/* hover 角标:取代原先每节点一张 256x64 CanvasTexture
(1151 张贴图 ≈ 72MB 显存)。DOM 文字更清晰且零 GPU 开销。*/
#sm-label {
position: absolute;
left: 10px;
bottom: 10px;
display: none;
max-width: 60%;
padding: 4px 10px;
font-size: 12px;
line-height: 1.4;
color: var(--text-primary);
background: rgba(10, 10, 20, 0.82);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
pointer-events: none;
z-index: 5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toggle-row {
margin-top: 8px;

View File

@ -66,6 +66,10 @@
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.4 9.4 0 0 1-3.3-.6L3 21l1.7-5A8.4 8.4 0 0 1 4 12.5 8.4 8.4 0 0 1 12.5 4h.5a8.4 8.4 0 0 1 8 7.5z"/></svg>
<span data-i18n="navChat">对话</span>
</a>
<a onclick="switchTab('starmap')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M12 2l2.2 6.3H21l-5.4 4 2.1 6.4L12 15.2 6.3 18.7l2.1-6.4-5.4-4h6.8z"/></svg>
<span data-i18n="navStarmap">星图</span>
</a>
<a onclick="switchTab('plugins')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M21 8l-9-5-9 5 9 5 9-5zM3 8v8l9 5 9-5V8"/></svg>
<span data-i18n="navPlugins">插件</span>
@ -176,6 +180,7 @@
<div class="container" id="app">
<div id="tab-overview" class="tab-content active"></div>
<div id="tab-chat" class="tab-content"></div>
<div id="tab-starmap" class="tab-content"></div>
<div id="tab-plugins" class="tab-content"></div>
<div id="tab-settings" class="tab-content"></div>
<div id="tab-adapters" class="tab-content"></div>

File diff suppressed because it is too large Load Diff

View File

@ -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))

View File

@ -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")
}
}

View File

@ -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"`