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:
@ -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;
|
||||
|
||||
@ -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
@ -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))
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user