perf(webui): pulse 端点不再全量扫描图库;修星图每帧全量开销

用户反馈「非常卡」。实测定位到两处(都在轮询/渲染的每帧路径上):

## 1. 后端:/memory/graph/pulse 名为轻量、实为全量

该端点自称轻量(注释写着「只回窗口内变动的实体,与完整图谱差两个数量级」),
但实现是**先调 GraphData() 取全量快照 → json.Marshal 约 3.3MB →
反序列化 → 才按时间过滤**。生产实测与全量端点**同价**:

    /memory/graph          135-147ms
    /memory/graph/pulse    144-157ms   ← 每 10s 被轮询一次

即每 10 秒白付一次全量序列化。生产库 3190 块 / 2692 边。

改法:新增 `GraphDB.BlocksChangedSince(since, limit)` —— 库侧 SQL 过滤
(`WHERE created_at >= ? OR updated_at >= ?` + LIMIT),响应体只含窗口内
的块。limit 上限保护:窗口内可能有很多块(批量迁移),而星图只需知道
「有新东西」。

接口走内置 `sdk.MemoryAPI`(非 pubsdk),与既有 GraphData 同理,
**不影响外部插件**。

## 2. 前端:星图渲染循环每帧全量遍历 + 永不停止

三处,都是我上一批星图改动引入的:

- **RAF 循环从不停**:切到聊天/终端页签、甚至页面不可见时,仍以 60fps
  跑着每帧 3190 节点的遍历。改用显式开关 + `starmapWake()` 重新点火
  (判据问「星图容器是否可见」,不用 document.hidden —— 星图可能在
  未激活的页签里,那时页面可见但星图看不到)。
- **starmapTickMorph 每帧扫全部节点**:3190 × 60fps = 每秒 19 万次
  userData 读取与分支,而绝大多数帧里「正在形变」的节点是 0 个。
  改用 `starmapMorphSet` 活跃集合,空集时 O(1) 返回。
- **标签层每帧重建 plan + measureText**:加 100ms 节流(文字层 10fps
  肉眼无差别,3D 图形仍 60fps)与 measureText 缓存(同步文字布局很贵,
  同文字同字体宽度不变)+ 缓存上限保护。

## 验证

- `blocks_changed_test.go`(新增):窗口语义、空窗口恒 0(否则星图会每次
  轮询重放生长动画)、limit 真的封顶
- 既有 pulse 判据全过(`OnlyRecent` / `SinceWidens` / `NoVector` /
  `IgnoresFrozenLegacyNodes`)—— 改写没破坏语义。
  测试替身 `fakeGraphMemory` 同步实现新方法,且**忠实反映过滤语义**
  (偷懒返回全部会把「窗口过滤」判据架空)
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
This commit is contained in:
JianFeeeee
2026-10-08 12:35:28 +08:00
parent 9d097febc9
commit 3e201dd4af
6 changed files with 202 additions and 29 deletions

View File

@ -173,6 +173,45 @@ func (g *GraphDB) PutDocumentNode(id, summary string) error {
}
// MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。
// BlocksChangedSince 返回自 since 之后**创建或更新过**的块(供星图轻量轮询)。
//
// ★ 为何需要专方法,而不是复用 GraphData() 再过滤(2026-10-08 实测):
//
// /memory/graph/pulse 本应是**轻量**端点,原实现却先调 GraphData()
// (全量 3190 块 + 2692 边)→ json.Marshal 约 3.3MB → 再反序列化 → 才按时间
// 过滤。实测它与全量端点**同价**(145ms vs 135ms),而它每 10s 被轮询一次。
//
// 这里直接用 SQL 在库侧过滤,响应体只含窗口内变动的块(几百字节~几 KB,
// 与全量差两个数量级)。
//
// limit 上限保护:窗口内也可能有大量块(如一次批量迁移),
// 而星图轮询只需知道“有新东西”,不需要全部。0 表示用默认上限。
func (g *GraphDB) BlocksChangedSince(since time.Time, limit int) ([]MemoryBlock, error) {
if limit <= 0 {
limit = 200
}
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT `+blockColumns+`
FROM memory_blocks
WHERE created_at >= ? OR updated_at >= ?
ORDER BY updated_at DESC LIMIT ?`, since, since, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var blocks []MemoryBlock
for rows.Next() {
block, err := scanBlockRow(rows)
if err != nil {
return nil, err
}
blocks = append(blocks, block)
}
return blocks, rows.Err()
}
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
g.mu.RLock()
defer g.mu.RUnlock()