mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
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:
163
internal/plugins/webui/handler_graph_pulse_test.go
Normal file
163
internal/plugins/webui/handler_graph_pulse_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user