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