From 97111778a98d60d597fad1f3810506794a29892a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 15:56:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E6=95=B0=E6=8D=AE=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=20API=20+=20=E5=89=8D=E7=AB=AF=20keyed=20=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=EF=BC=8C=E5=8E=BB=E6=8E=89=E3=80=8C=E5=B1=80=E9=83=A8?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=E3=80=8D=E7=9A=84=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jianf:局部重建的闪烁几乎消不掉,应暴露数据查询 api,前端轮询后增量更新视图, 聊天记录也用这套。 后端(数据查询 api): - ChatMsg 增加 seq(服务端单调递增、随记录落盘);老记录加载时补 1..n,重启不重编号。 - /api/v1/chat/history 增加 after= 增量通道:只回 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 轮询。 --- internal/plugins/webui/dashboard.js | 262 ++++++++++++++++++++++--- internal/plugins/webui/handler.go | 3 +- internal/plugins/webui/handler_chat.go | 84 +++++++- internal/plugins/webui/handler_test.go | 69 +++++++ 4 files changed, 390 insertions(+), 28 deletions(-) diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index eaf2923..77f8920 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -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 @@ "

"; } 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 += '
' + + '" data-key="' + _k + '">
' + '
' + srcLabel + "
" + @@ -1721,12 +1844,12 @@ } if (role === "system") { html += - '
' + + '
' + (c || "") + "
"; } else if (isChan) { html += - '
' + + '
' + '
' + @@ -1744,7 +1867,7 @@ html += '
' + + '" data-key="' + _k + '">' + '
' + (role === "user" ? userAvatar : aiAvatar) + "
" + @@ -1758,7 +1881,7 @@ if (state.chatLoading && !streamingLast) { var aiAvatarL = '小宅'; html += - '
' + + '
' + aiAvatarL + '
' + '' + @@ -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); diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 0df10e2..c17c44a 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -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 是聊天记录的独立存储(默认 /webui_chat_history.json, // 插件设置 history_file 可改)。 diff --git a/internal/plugins/webui/handler_chat.go b/internal/plugins/webui/handler_chat.go index 43c3dd0..2981e5c 100644 --- a/internal/plugins/webui/handler_chat.go +++ b/internal/plugins/webui/handler_chat.go @@ -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=)与前端列表的稳定 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, }) } diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index e694e8a..1061e07 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -1388,6 +1388,75 @@ func TestChatHistoryDefaultIsPaged(t *testing.T) { } } +// TestChatHistoryIncrementalAfterCursor 钉住增量游标 API(前端轮询只拿增量、 +// 不做整块重建的根据)。 +// +// 三条不变量: +// - after= 只回 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 }