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:
JianFeeeee
2026-09-14 15:56:27 +08:00
parent f722498dba
commit 97111778a9
4 changed files with 390 additions and 28 deletions

View File

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