mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
feat(webui): 数据查询 API + 前端 keyed 对账,去掉「局部重建」的闪烁
jianf:局部重建的闪烁几乎消不掉,应暴露数据查询 api,前端轮询后增量更新视图, 聊天记录也用这套。 后端(数据查询 api): - ChatMsg 增加 seq(服务端单调递增、随记录落盘);老记录加载时补 1..n,重启不重编号。 - /api/v1/chat/history 增加 after=<seq> 增量通道:只回 seq 更大的消息,返回 last_seq 作下次游标;一批超 limit 时回**最旧**的一批(回最新会把被挤掉的旧消息永久漏掉)。 普通响应也带 last_seq,客户端首次全量后据此初始化游标。 - 测试 TestChatHistoryIncrementalAfterCursor 钉住「不重不漏 + 截断停在返回的最后一条」。 前端: - 新增通用 morph():按「子节点位置 + nodeName」递归对账 DOM,同名节点复用、只同步 变化的属性与文本。运行态面板的 put() 由 innerHTML 重建改为 morph —— SVG 圆环、 队列条、数字块这些未变节点不再被替换,CSS 过渡与动画不再从头播。 - 聊天列表改用 keyed commitChatList():按 data-key(服务端 seq / 本地临时 key)对账, 未变消息节点一个字节都不动,只替换真正变化的那条。 - syncChatFromHistory 改走游标:pollChatIncremental() 用 after 拿增量 + tail=1 探尾部 原地更新(工具调用/最终文本是原地改的,不产生新 seq);聊天页可见时 3s 轮询。
This commit is contained in:
@ -1388,6 +1388,75 @@ func TestChatHistoryDefaultIsPaged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatHistoryIncrementalAfterCursor 钉住增量游标 API(前端轮询只拿增量、
|
||||
// 不做整块重建的根据)。
|
||||
//
|
||||
// 三条不变量:
|
||||
// - after=<seq> 只回 seq 更大的消息,按 seq 升序;
|
||||
// - 响应带 last_seq,续取不重不漏;
|
||||
// - 一批超过 limit 时返回**最旧的一批**并把 last_seq 停在返回的最后一条
|
||||
// (若返回最新一批,被挤掉的旧几条就永远追不回来了)。
|
||||
func TestChatHistoryIncrementalAfterCursor(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
h.chatMu.Lock()
|
||||
h.chatHistory = nil
|
||||
h.chatSeq = 0
|
||||
for i := 0; i < 5; i++ {
|
||||
h.chatHistory = append(h.chatHistory, ChatMsg{Seq: h.bumpSeqLocked(), Role: "user", Content: "m", Time: "t"})
|
||||
}
|
||||
h.chatMu.Unlock()
|
||||
|
||||
get := func(q string) map[string]interface{} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/history"+q, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleChatHistory(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %q: %d", q, w.Code)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
first := get("?after=0&limit=2")
|
||||
if n := len(first["messages"].([]interface{})); n != 2 {
|
||||
t.Fatalf("after=0&limit=2 应回 2 条,实际 %d", n)
|
||||
}
|
||||
if got := first["last_seq"].(float64); got != 2 {
|
||||
t.Fatalf("截断时 last_seq 必须停在返回的最后一条(2),实际 %v", got)
|
||||
}
|
||||
|
||||
second := get("?after=2&limit=2")
|
||||
if got := second["last_seq"].(float64); got != 4 {
|
||||
t.Fatalf("续取 last_seq 应为 4,实际 %v", got)
|
||||
}
|
||||
third := get("?after=4&limit=2")
|
||||
if n := len(third["messages"].([]interface{})); n != 1 {
|
||||
t.Fatalf("after=4 应只剩 1 条,实际 %d", n)
|
||||
}
|
||||
if got := third["last_seq"].(float64); got != 5 {
|
||||
t.Fatalf("追平后 last_seq 应为 5,实际 %v", got)
|
||||
}
|
||||
|
||||
none := get("?after=5")
|
||||
if n := len(none["messages"].([]interface{})); n != 0 {
|
||||
t.Fatalf("无新增应回 0 条,实际 %d", n)
|
||||
}
|
||||
if got := none["last_seq"].(float64); got != 5 {
|
||||
t.Fatalf("无新增时 last_seq 应保持传入值 5,实际 %v", got)
|
||||
}
|
||||
|
||||
// 非数字游标 → 400(而不是静默当成 0 全量重投)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/history?after=abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleChatHistory(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法 after 应为 400,实际 %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeStatus 是给 /runtime 用的最小内核状态桩。
|
||||
type fakeStatus struct{ ks *sdk.KernelStatus }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user