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

@ -29,6 +29,7 @@
chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标
chatTotal: 0, // 服务端历史总条数
chatHasMore: false, // 是否还有更早历史可向上加载
chatLastSeq: 0, // 增量轮询游标:已合并到本地的最大消息 seq
lang: localStorage.getItem("ha-lang") || "zh",
};
@ -604,6 +605,58 @@
// 视觉效果就是“首页一闪一闪”。签名相同就一个字节也不动。
var _rtSig = null;
// morph把容器的 DOM 就地「形变」成 html 描述的样子。
//
// 为什么不用 innerHTML = html整块重建会把**没变的**节点也换掉 ——
// SVG 过渡从头播、图片重解码、展开态丢失、滚动锚点重置。运行态每几秒
// 刷一次,未变节点占绝大多数,那些闪烁就是这么来的。
//
// 做法:按「子节点位置 + nodeName」递归对账。同名元素复用同一个 DOM 节点,
// 只同步发生变化的属性与文本;只有标签真的不同才替换。元素身份不变 ⇒
// CSS 过渡继续、不闪。
function morph(container, html) {
var tpl = document.createElement("div");
tpl.innerHTML = html;
morphChildren(container, tpl);
}
function morphChildren(oldParent, newParent) {
var olds = Array.prototype.slice.call(oldParent.childNodes);
var news = Array.prototype.slice.call(newParent.childNodes);
var n = Math.max(olds.length, news.length);
for (var i = 0; i < n; i++) {
var o = olds[i], x = news[i];
if (!o && x) {
oldParent.appendChild(x);
continue;
}
if (o && !x) {
oldParent.removeChild(o);
continue;
}
if (o.nodeType !== x.nodeType || o.nodeName !== x.nodeName) {
oldParent.replaceChild(x, o);
continue;
}
if (o.nodeType === 3 || o.nodeType === 8) {
if (o.nodeValue !== x.nodeValue) o.nodeValue = x.nodeValue;
continue;
}
morphAttrs(o, x);
morphChildren(o, x);
}
}
function morphAttrs(o, x) {
var i, a;
for (i = o.attributes.length - 1; i >= 0; i--) {
a = o.attributes[i];
if (!x.hasAttribute(a.name)) o.removeAttribute(a.name);
}
for (i = 0; i < x.attributes.length; i++) {
a = x.attributes[i];
if (o.getAttribute(a.name) !== a.value) o.setAttribute(a.name, a.value);
}
}
// rtSlider 把“值 / 上限”画成轨道 + 滑块 + 读数(比纯文本数字直观)。
function rtSlider(value, max, label, display) {
var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
@ -984,7 +1037,8 @@
var n = document.getElementById("rt-sec-" + sec);
if (!n || n.__sig === html) return; // 该段没变:一个字节都不动
n.__sig = html;
n.innerHTML = html;
// 变了也只「形变」到新样子:复用未变的子节点,而不是整块重建。
morph(n, html);
}
var q = sc.interrupt_queues || [0, 0, 0, 0, 0];
@ -1103,6 +1157,19 @@
// startRuntimeTicker 起 3s 轮询:运行态只在总览页可见时才有意义,
// 其它页签上不浪费请求。切回总览时 renderAll 会立刻再拉一次,不必等下一拍。
// startChatTicker聊天页可见时 3s 轮询一次增量(与运行态同一节奏)。
// SSE 仍在token 级流式靠它),轮询是「数据查询 api」那条腿
// 界面最终状态由轮询拉到的数据决定SSE 只负责让流式看起来即时。
function startChatTicker() {
if (state._chatTicker) return;
state._chatTicker = setInterval(function () {
var tab = document.querySelector("#tab-chat");
if (tab && tab.classList.contains("active")) {
syncChatFromHistory().catch(function () {});
}
}, 3000);
}
function startRuntimeTicker() {
if (state._runtimeTicker) return;
state._runtimeTicker = setInterval(function () {
@ -1425,6 +1492,61 @@
return /[A-Za-z0-9]/.test(ch) ? ch : "C";
}
// ===== 列表对账keyed reconcile=====
//
// 为什么必须对账而不是 innerHTML 整块重建:整块重建会把**没变的**那些消息
// 节点也推倒重来 —— 图片重新解码闪一下、工具卡片的展开态丢失、CSS 过渡从
// 头播、滚动锚点被重置。这些闪烁几乎无法用"重建后再还原"消除。
//
// 做法:新 HTML 先在游离容器里解析成节点,然后按 data-key 逐个对账:
// - key 命中且内容一致 → 复用原节点(一个字节都不动);
// - key 命中但内容变了 → 只替换这一个节点;
// - key 未命中 → 作为新节点插入;
// 最后把多出来的旧节点删掉。未变节点因此完全不受影响。
var _localKeySeq = 0;
// chatMsgKey 给一条消息一个**稳定**的 key。
// - 服务端消息用 seq单调、落盘、重启不重编号
// - 本地乐观消息(用户刚发的 / 正在流式的)第一次渲染时分配一个进程内
// 自增 key 并挂在对象上,后续渲染不变。
function chatMsgKey(m) {
if (m && m.seq) return "s" + m.seq;
if (m && !m._k) m._k = "L" + ++_localKeySeq;
return (m && m._k) || "L0";
}
function commitChatList(container, html) {
var tpl = document.createElement("div");
tpl.innerHTML = html;
var next = Array.prototype.slice.call(tpl.children);
var existing = {};
Array.prototype.forEach.call(container.children, function (n) {
var k = n.getAttribute && n.getAttribute("data-key");
if (k) existing[k] = n;
});
for (var i = 0; i < next.length; i++) {
var fresh = next[i];
var key = fresh.getAttribute && fresh.getAttribute("data-key");
var node = fresh;
if (key && existing[key]) {
var old = existing[key];
// 内容逐字节相同 → 复用旧节点(不触碰它,保存动画/展开/图片状态);
// 否则只替换这一个。outerHTML 比较对消息节点足够(含 tool_calls 状态)。
if (old.outerHTML === fresh.outerHTML) {
node = old;
} else {
old.remove();
}
}
var cur = container.children[i];
if (cur !== node) container.insertBefore(node, cur || null);
}
// 多出来的旧节点(本轮新列表里没有的)从尾部清掉
while (container.children.length > next.length) {
container.removeChild(container.lastElementChild);
}
}
function renderChat() {
if (!_chatLayoutBuilt) {
buildChatLayout();
@ -1540,6 +1662,7 @@
"</p></div>";
} else {
msgs.forEach(function (m, i) {
var _k = chatMsgKey(m);
var role = m.role || "user";
var c = m.content || "";
// 附件消息agent 经 output_send__webui 发送,或用户上传。
@ -1595,7 +1718,7 @@
html +=
'<div class="msg ' +
(isUserAtt ? "user" : "assistant") +
'"><div class="msg-bubble"><div class="att-wrap">' +
'" data-key="' + _k + '"><div class="msg-bubble"><div class="att-wrap">' +
'<div style="font-size:11px;opacity:0.65;margin-bottom:4px">' +
srcLabel +
"</div>" +
@ -1721,12 +1844,12 @@
}
if (role === "system") {
html +=
'<div class="msg msg-system"><div class="msg-bubble">' +
'<div class="msg msg-system" data-key="' + _k + '"><div class="msg-bubble">' +
(c || "") +
"</div></div>";
} else if (isChan) {
html +=
'<div class="msg msg-channel">' +
'<div class="msg msg-channel" data-key="' + _k + '">' +
'<div class="msg-avatar chan-avatar" style="background:' +
chanColor(m.source) +
'">' +
@ -1744,7 +1867,7 @@
html +=
'<div class="msg msg-' +
role +
'">' +
'" data-key="' + _k + '">' +
'<div class="msg-avatar">' +
(role === "user" ? userAvatar : aiAvatar) +
"</div>" +
@ -1758,7 +1881,7 @@
if (state.chatLoading && !streamingLast) {
var aiAvatarL = '<img src="/mascot.webp" alt="小宅">';
html +=
'<div class="msg msg-assistant"><div class="msg-avatar">' +
'<div class="msg msg-assistant" data-key="pending"><div class="msg-avatar">' +
aiAvatarL +
'</div><div class="msg-content"><div class="msg-bubble">' +
'<span class="live-spinner"></span>' +
@ -1770,7 +1893,7 @@
// 重建前记住阅读位置非粘底用户正向上翻innerHTML 重建后必须把位置还回去,
// 否则视口会被重置——这就是"聊天记录跳到顶部"的直接来源。
var prevTop = msgsEl.scrollTop;
msgsEl.innerHTML = html;
commitChatList(msgsEl, html);
if (state.chatStick !== false) {
msgsEl.scrollTop = msgsEl.scrollHeight;
} else {
@ -2683,10 +2806,114 @@
state.chatOffset = typeof data.offset === "number" ? data.offset : 0;
state.chatTotal = typeof data.total === "number" ? data.total : data.messages.length;
state.chatHasMore = !!data.has_more;
state.chatLastSeq = historyLastSeq(data);
}
} catch (e) {}
}
// historyLastSeq 从一次 /chat/history 响应里取出「已见到的最大 seq」
// 优先用服务端给的 last_seq缺了就取消息里的最大值。
function historyLastSeq(data) {
if (!data) return 0;
if (typeof data.last_seq === "number") return data.last_seq;
var mx = 0;
(data.messages || []).forEach(function (m) {
if (m && m.seq > mx) mx = m.seq;
});
return mx;
}
// applyServerMessages 把服务端消息并进 state.messages按 seq 对账:
// - 同 seq 已存在 → 原地替换(工具调用/最终文本是原地更新);
// - 不存在 → 追加(若末尾是无 seq 的乐观消息且 role+content 一致,则替换它,
// 避免"自己刚发的那条"重复成两条)。
// tailOnly=true 时只做原地更新与"比本地新才追加",不把尾探测当成新消息。
// @returns {boolean} 是否真的改动了 state.messages
function applyServerMessages(list, tailOnly) {
if (!list || !list.length) return false;
var msgs = state.messages;
var changed = false;
function carry(prev, sm) {
if (prev && prev._final) sm._final = true;
if (prev && prev._grow) sm._grow = true;
return sm;
}
list.forEach(function (sm) {
if (!sm) return;
var seq = sm.seq;
var found = -1;
for (var j = msgs.length - 1; j >= 0 && j >= msgs.length - 12; j--) {
if (seq && msgs[j] && msgs[j].seq === seq) {
found = j;
break;
}
}
if (found >= 0) {
if (JSON.stringify(msgs[found]) !== JSON.stringify(sm)) {
msgs[found] = carry(msgs[found], sm);
changed = true;
}
return;
}
if (tailOnly) {
var lastS = msgs.length ? msgs[msgs.length - 1].seq : 0;
if (seq && (!lastS || seq > lastS)) {
msgs.push(sm);
changed = true;
}
return;
}
if (msgs.length) {
var last = msgs[msgs.length - 1];
if (
!last.seq &&
(last.role || "") === (sm.role || "") &&
(last.content || "") === (sm.content || "")
) {
msgs[msgs.length - 1] = carry(last, sm);
changed = true;
return;
}
}
msgs.push(sm);
changed = true;
});
list.forEach(function (sm) {
if (sm && sm.seq > (state.chatLastSeq || 0)) state.chatLastSeq = sm.seq;
});
if (changed) rerenderChat();
return changed;
}
// pollChatIncremental 轮询「自上次以来新增了什么」。
//
// 这就是「暴露数据查询 api前端轮询后 patch 视图」那条路after=游标
// 只拿增量,再单独探一次尾部做原地更新(工具调用/最终文本是原地改的,
// 不会产生新 seq只靠 after 拿不到)。视图更新走 commitChatList 的
// keyed 对账,未变消息节点一个字节都不动 —— 闪烁由此消失。
function pollChatIncremental() {
var after = state.chatLastSeq || 0;
if (!after) {
// 还没建立游标(首次 / 本地为空):退回一次性全量,交给已有一致性逻辑
return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then(function (data) {
state.chatLastSeq = historyLastSeq(data);
return mergeChatFromHistory(data);
});
}
return api("/chat/history?after=" + after)
.then(function (data) {
if (!data) return;
state.chatLastSeq = historyLastSeq(data) || after;
applyServerMessages(data.messages || [], false);
return api("/chat/history?limit=1");
})
.then(function (tail) {
if (tail && tail.messages && tail.messages.length) {
applyServerMessages(tail.messages.slice(-1), true);
}
});
}
// loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。
// 保持滚动位置(插入前后 scrollHeight 差值补偿),避免视口跳动。
var _loadingOlder = false;
@ -2730,27 +2957,11 @@
// 生产消息更大时可达 ~1MB —— 这是"每次都在发完整聊天记录"的观感来源。
// 探测只需 1 条(约几 KB尾巴一致就直接跳过。
var _syncingChat = false;
// syncChatFromHistory 保留旧名(多处调用点):内部改走游标增量轮询。
function syncChatFromHistory() {
if (_syncingChat) return Promise.resolve();
_syncingChat = true;
return api("/chat/history?limit=1")
.then(function (tail) {
var t = tail && tail.messages && tail.messages[0];
var local = state.messages.length
? state.messages[state.messages.length - 1]
: null;
var same =
t &&
local &&
t.role === (local.role || local.Role) &&
(t.content || "") === (local.content || local.Content || "");
if (same) return null; // 尾巴一致:无需拉整页
return api("/chat/history?limit=" + CHAT_PAGE_SIZE);
})
.then(function (data) {
if (!data) return;
return mergeChatFromHistory(data);
})
return pollChatIncremental()
.catch(function () {})
.then(function () {
_syncingChat = false;
@ -5046,6 +5257,7 @@
connectSSE();
startUptimeTicker();
startRuntimeTicker();
startChatTicker();
maybeShowPersonaWizard();
})();
setInterval(renderAll, 15000);

View File

@ -105,7 +105,8 @@ type Handler struct {
chatMu sync.Mutex
chatHistory []ChatMsg
pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无
pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无
chatSeq int64 // 已分配的最大序号;单调递增,作增量查询游标
// history 是聊天记录的独立存储(默认 <data>/webui_chat_history.json
// 插件设置 history_file 可改)。

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
@ -24,6 +25,13 @@ type ChatMsg struct {
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
Source string `json:"source,omitempty"`
Time string `json:"time"`
// Seq 是服务端分配的**单调递增**序号,作为增量查询游标
// GET /chat/history?after=<seq>)与前端列表的稳定 key。
//
// 为什么不能用下标chatHistory 有上限maxChatHistory=200超出从头丢
// 下标会整体前移 —— 拿它当游标要么重复要么漏消息。seq 只增不减,且随
// 记录一起落盘,重启后编号不重置。
Seq int64 `json:"seq"`
// Attachment 附件输出output_send__webui type=image/file
// image 前端内联展示file 渲染下载卡片。nil 表示纯文本消息。
Attachment *Attachment `json:"attachment,omitempty"`
@ -95,6 +103,22 @@ func (h *Handler) loadChatHistory() {
}
h.chatMu.Lock()
h.chatHistory = msgs
// 老记录没有 seq本字段是后加的补成 1..n 并把计数器顶到最大。
// 只补缺失的,已有序号原样保留 —— 重启不能重编号,否则客户端的 after 游标
// 会指向另一条消息。
var maxSeq int64
for i := range h.chatHistory {
if h.chatHistory[i].Seq > maxSeq {
maxSeq = h.chatHistory[i].Seq
}
}
for i := range h.chatHistory {
if h.chatHistory[i].Seq == 0 {
maxSeq++
h.chatHistory[i].Seq = maxSeq
}
}
h.chatSeq = maxSeq
h.chatMu.Unlock()
}
@ -169,7 +193,7 @@ func (h *Handler) subscribeChatEvents() {
h.chatMu.Lock()
msg := h.pendingAssistantLocked()
if msg == nil {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Seq: h.bumpSeqLocked(), Time: time.Now().Format(time.RFC3339)})
h.pendingIdx = len(h.chatHistory) - 1
msg = &h.chatHistory[h.pendingIdx]
}
@ -189,7 +213,7 @@ func (h *Handler) subscribeChatEvents() {
h.chatMu.Lock()
msg := h.pendingAssistantLocked()
if msg == nil {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Seq: h.bumpSeqLocked(), Time: time.Now().Format(time.RFC3339)})
h.pendingIdx = len(h.chatHistory) - 1
msg = &h.chatHistory[h.pendingIdx]
}
@ -226,6 +250,7 @@ func (h *Handler) subscribeChatEvents() {
}
m := ChatMsg{
Role: "assistant",
Seq: h.bumpSeqLocked(),
Source: channel,
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
Attachment: att,
@ -456,8 +481,16 @@ func (h *Handler) handleToolEvent(ev *sdk.Event) {
}
}
// bumpSeqLocked 分配下一个聊天序号(调用方须持 chatMu
// 序号单调递增、随记录落盘,作为 /chat/history?after= 的增量游标。
func (h *Handler) bumpSeqLocked() int64 {
h.chatSeq++
return h.chatSeq
}
func (h *Handler) addChatMsg(msg ChatMsg) {
h.chatMu.Lock()
msg.Seq = h.bumpSeqLocked()
h.chatHistory = append(h.chatHistory, msg)
if len(h.chatHistory) > maxChatHistory {
drop := len(h.chatHistory) - maxChatHistory
@ -497,6 +530,46 @@ func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
limit = maxChatHistory
}
// after**增量游标**。只返回 seq > after 的消息,按 seq 升序。
//
// 这是给「轮询查询数据再更新视图」用的:客户端存下 last_seq下次带回来
// 只拿新增/变化的部分去 patch 视图,不做整块重建(重建的闪烁消不掉)。
// 与 before向上翻页互斥after 优先。
//
// 返回的是新增里**最旧的一批**(最多 limit 条last_seq 是这批最后一条 ——
// 客户端据此继续追下一批。若改成返回最新一批,被挤掉的旧的那几条就永远
// 追不回来了。
if raw := strings.TrimSpace(q.Get("after")); raw != "" {
after, err := strconv.ParseInt(raw, 10, 64)
if err != nil || after < 0 {
http.Error(w, "invalid after", http.StatusBadRequest)
return
}
h.chatMu.Lock()
total := len(h.chatHistory)
out := make([]ChatMsg, 0, 16)
for i := range h.chatHistory {
if h.chatHistory[i].Seq > after {
out = append(out, h.chatHistory[i])
}
}
h.chatMu.Unlock()
if limit > 0 && len(out) > limit {
out = out[:limit]
}
lastSeq := after
if len(out) > 0 {
lastSeq = out[len(out)-1].Seq
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"messages": out,
"total": total,
"after": after,
"last_seq": lastSeq,
})
return
}
h.chatMu.Lock()
total := len(h.chatHistory)
// before 游标:默认取到末尾(最新)
@ -510,13 +583,20 @@ func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
}
result := make([]ChatMsg, end-start)
copy(result, h.chatHistory[start:end])
// last_seq 在锁内取:放锁后再读 h.chatHistory 是数据竞争。
var lastSeq int64
if total > 0 {
lastSeq = h.chatHistory[total-1].Seq
}
h.chatMu.Unlock()
// last_seq 一并下发:客户端首次全量加载后据此初始化增量游标。
writeJSON(w, http.StatusOK, map[string]interface{}{
"messages": result,
"total": total,
"offset": start,
"has_more": start > 0,
"last_seq": lastSeq,
})
}

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 }